Merge branch 'master' into ebarriosjr/Creating-gcp-projects-plugin

This commit is contained in:
Esteban Barrios
2020-09-03 17:59:59 +02:00
committed by GitHub
267 changed files with 5449 additions and 2169 deletions
+9 -9
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-api-docs",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,16 +20,15 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/plugin-catalog": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@kyma-project/asyncapi-react": "^0.11.0",
"@material-icons/font": "^1.0.2",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"@types/react": "^16.9",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router-dom": "6.0.0-beta.0",
@@ -37,14 +36,15 @@
"swagger-ui-react": "^3.31.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@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",
"@types/react": "^16.9",
"@types/swagger-ui-react": "^3.23.3",
"jest-fetch-mock": "^3.0.3"
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { Table, TableColumn } from '@backstage/core';
import { Link } from '@material-ui/core';
@@ -16,13 +16,15 @@
import { ApiEntityV1alpha1 } from '@backstage/catalog-model';
import { InfoCard } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
import { ApiDefinitionWidget } from '../ApiDefinitionWidget/ApiDefinitionWidget';
export const ApiDefinitionCard: FC<{
type Props = {
title?: string;
apiEntity: ApiEntityV1alpha1;
}> = ({ title, apiEntity }) => {
};
export const ApiDefinitionCard = ({ title, apiEntity }: Props) => {
const type = apiEntity?.spec?.type || '';
const definition = apiEntity?.spec?.definition || '';
@@ -14,15 +14,17 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import React from 'react';
import { AsyncApiDefinitionWidget } from '../AsyncApiDefinitionWidget/AsyncApiDefinitionWidget';
import { OpenApiDefinitionWidget } from '../OpenApiDefinitionWidget/OpenApiDefinitionWidget';
import { PlainApiDefinitionWidget } from '../PlainApiDefinitionWidget/PlainApiDefinitionWidget';
export const ApiDefinitionWidget: FC<{
type Props = {
type: string;
definition: string;
}> = ({ type, definition }) => {
};
export const ApiDefinitionWidget = ({ type, definition }: Props) => {
switch (type) {
case 'openapi':
return <OpenApiDefinitionWidget definition={definition} />;
@@ -31,6 +33,8 @@ export const ApiDefinitionWidget: FC<{
return <AsyncApiDefinitionWidget definition={definition} />;
default:
return <PlainApiDefinitionWidget definition={definition} />;
return (
<PlainApiDefinitionWidget definition={definition} language={type} />
);
}
};
@@ -29,7 +29,7 @@ import {
import { catalogApiRef } from '@backstage/plugin-catalog';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect } from 'react';
import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { ApiDefinitionCard } from '../ApiDefinitionCard/ApiDefinitionCard';
@@ -59,15 +59,18 @@ export const getPageTheme = (entity?: Entity): PageTheme => {
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
title,
}) => (
type EntityPageTitleProps = {
title: string;
entity: Entity | undefined;
};
const EntityPageTitle = ({ title }: EntityPageTitleProps) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
</Box>
);
export const ApiEntityPage: FC<{}> = () => {
export const ApiEntityPage = () => {
const { optionalNamespaceAndName } = useParams() as {
optionalNamespaceAndName: string;
};
@@ -15,7 +15,7 @@
*/
import AsyncApi from '@kyma-project/asyncapi-react';
import React, { FC } from 'react';
import React from 'react';
import { makeStyles, fade } from '@material-ui/core/styles';
import '@kyma-project/asyncapi-react/lib/styles/fiori.css';
@@ -135,9 +135,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const AsyncApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const AsyncApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
return (
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import React, { FC, useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -65,9 +65,11 @@ const useStyles = makeStyles(theme => ({
},
}));
export const OpenApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
};
export const OpenApiDefinitionWidget = ({ definition }: Props) => {
const classes = useStyles();
// Due to a bug in the swagger-ui-react component, the component needs
@@ -15,10 +15,13 @@
*/
import { CodeSnippet } from '@backstage/core';
import React, { FC } from 'react';
import React from 'react';
export const PlainApiDefinitionWidget: FC<{
type Props = {
definition: any;
}> = ({ definition }) => {
return <CodeSnippet text={definition} language="yaml" />;
language: string;
};
export const PlainApiDefinitionWidget = ({ definition, language }: Props) => {
return <CodeSnippet text={definition} language={language} />;
};
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint.backend')],
};
+32
View File
@@ -0,0 +1,32 @@
# App backend plugin
This backend plugin can be installed to serve static content of a Backstage app.
## Installation
Add both this package and your local frontend app package as dependencies to your backend, for example
```bash
yarn add @backstage/plugin-app-backend example-app
```
By adding the app package as a dependency we ensure that it is built as part of the backend, and that it can be resolved at runtime.
Now add the plugin router to your app, creating it for example like this:
```ts
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
```
And registering it like this:
```ts
createServiceBuilder(module)
...
.addRouter('', router);
```
Be sure to register the app router last, as it serves content for HTML5-mode navigation, i.e. falling back to serving `index.html` for any route that can't be found.
+42
View File
@@ -0,0 +1,42 @@
{
"name": "@backstage/plugin-app-backend",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.cjs.js",
"types": "dist/index.d.ts"
},
"scripts": {
"start": "backstage-cli backend:dev",
"build": "backstage-cli backend:build",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config-loader": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.21",
"@types/supertest": "^2.0.8",
"msw": "^0.19.5",
"supertest": "^4.0.2"
},
"files": [
"dist",
"static"
]
}
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunsPage } from './WorkflowRunsPage';
export * from './service/router';
+136
View File
@@ -0,0 +1,136 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import { injectEnvConfig } from './config';
jest.mock('fs-extra');
const fsMock = fs as jest.Mocked<typeof fs>;
const readFileMock = (fsMock.readFile as unknown) as jest.MockedFunction<
(name: string) => Promise<string>
>;
const MOCK_DIR = 'mock-dir';
const baseOptions = {
env: {},
staticDir: MOCK_DIR,
logger: getVoidLogger(),
};
describe('injectEnvConfig', () => {
beforeEach(() => {
fsMock.readdir.mockResolvedValue(['main.js']);
});
afterEach(() => {
jest.resetAllMocks();
});
it('should not inject without config', async () => {
await injectEnvConfig(baseOptions);
expect(fsMock.readdir).toHaveBeenCalledTimes(0);
expect(fsMock.readFile).toHaveBeenCalledTimes(0);
expect(fsMock.writeFile).toHaveBeenCalledTimes(0);
});
it('should find the correct file to inject', async () => {
fsMock.readdir.mockResolvedValue([
'before.js',
'not-js.txt',
'main.js',
'after.js',
]);
readFileMock.mockImplementation(async (file: string) => {
if (file.endsWith('main.js')) {
return '"__APP_INJECTED_RUNTIME_CONFIG__"';
}
return 'NO_PLACEHOLDER_HERE';
});
await injectEnvConfig({ ...baseOptions, env: { APP_CONFIG_x: '0' } });
expect(fsMock.readFile).toHaveBeenCalledTimes(2);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
1,
resolvePath(MOCK_DIR, 'before.js'),
'utf8',
);
expect(fsMock.readFile).toHaveBeenNthCalledWith(
2,
resolvePath(MOCK_DIR, 'main.js'),
'utf8',
);
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/',
'utf8',
);
// eslint-disable-next-line no-eval
expect(JSON.parse(eval(fsMock.writeFile.mock.calls[0][1]))).toEqual({
x: 0,
});
});
it('should re-inject config', async () => {
fsMock.readdir.mockResolvedValue(['main.js']);
readFileMock.mockResolvedValue(
'JSON.parse("__APP_INJECTED_RUNTIME_CONFIG__")',
);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '0',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(1);
expect(fsMock.writeFile).toHaveBeenCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":0}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[0][1])).toEqual({ x: 0 });
readFileMock.mockResolvedValue(fsMock.writeFile.mock.calls[0][1]);
await injectEnvConfig({
...baseOptions,
env: {
APP_CONFIG_x: '1',
APP_CONFIG_y: '2',
},
});
expect(fsMock.writeFile).toHaveBeenCalledTimes(2);
expect(fsMock.writeFile).toHaveBeenLastCalledWith(
resolvePath(MOCK_DIR, 'main.js'),
'JSON.parse(/*__APP_INJECTED_CONFIG_MARKER__*/"{\\"x\\":1,\\"y\\":2}"/*__INJECTED_END__*/)',
'utf8',
);
// eslint-disable-next-line no-eval
expect(eval(fsMock.writeFile.mock.calls[1][1])).toEqual({ x: 1, y: 2 });
});
});
+74
View File
@@ -0,0 +1,74 @@
/*
* 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 fs from 'fs-extra';
import { resolve as resolvePath } from 'path';
import { readEnvConfig } from '@backstage/config-loader';
import { Logger } from 'winston';
type Options = {
// Environment to read config from
env: { [name: string]: string | undefined };
// Directory of the static JS files to search for file to inject
staticDir: string;
logger: Logger;
};
/**
* Injects config from APP_CONFIG_ env vars, replacing existing
* injected config if it has already been injected.
*/
export async function injectEnvConfig(options: Options) {
const { env, staticDir, logger } = options;
const envConfig = readEnvConfig(env);
if (envConfig.length === 0) {
return;
}
const files = await fs.readdir(staticDir);
const jsFiles = files.filter(file => file.endsWith('.js'));
const [{ data }] = envConfig;
const escapedData = JSON.stringify(data).replace(/("|'|\\)/g, '\\$1');
const injected = `/*__APP_INJECTED_CONFIG_MARKER__*/"${escapedData}"/*__INJECTED_END__*/`;
for (const jsFile of jsFiles) {
const path = resolvePath(staticDir, jsFile);
const content = await fs.readFile(path, 'utf8');
if (content.includes('__APP_INJECTED_RUNTIME_CONFIG__')) {
logger.info(`Injecting env config into ${jsFile}`);
const newContent = content.replace(
'"__APP_INJECTED_RUNTIME_CONFIG__"',
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
} else if (content.includes('__APP_INJECTED_CONFIG_MARKER__')) {
logger.info(`Replacing injected env config in ${jsFile}`);
const newContent = content.replace(
/\/\*__APP_INJECTED_CONFIG_MARKER__\*\/.*\/\*__INJECTED_END__\*\//,
injected,
);
await fs.writeFile(path, newContent, 'utf8');
return;
}
}
logger.info('Env config not injected');
}
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 { getRootLogger } from '@backstage/backend-common';
import yn from 'yn';
import { startStandaloneServer } from './service/standaloneServer';
const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000;
const enableCors = yn(process.env.PLUGIN_CORS, { default: false });
const logger = getRootLogger();
startStandaloneServer({ port, enableCors, logger }).catch(err => {
logger.error(err);
process.exit(1);
});
process.on('SIGINT', () => {
logger.info('CTRL+C pressed; exiting.');
process.exit(0);
});
@@ -0,0 +1 @@
!dist
@@ -0,0 +1 @@
this is index.html
@@ -0,0 +1 @@
this is other.html
@@ -0,0 +1 @@
this is main.txt
@@ -0,0 +1,95 @@
/*
* 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 { resolve as resolvePath } from 'path';
import { getVoidLogger } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import request from 'supertest';
import { createRouter } from './router';
jest.mock('../lib/config', () => ({ injectEnvConfig: jest.fn() }));
global.__non_webpack_require__ = {
resolve: () => resolvePath(__dirname, '__fixtures__/app-dir/package.json'),
};
describe('createRouter', () => {
let app: express.Express;
beforeAll(async () => {
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
it('returns index.html', async () => {
const response = await request(app).get('/index.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
it('returns other.html', async () => {
const response = await request(app).get('/other.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is other.html\n');
});
it('returns index.html if missing', async () => {
const response = await request(app).get('/missing.html');
expect(response.status).toBe(200);
expect(response.text).toBe('this is index.html\n');
});
});
describe('createRouter with static fallback handler', () => {
it('uses static fallback handler', async () => {
const staticFallbackHandler = Router();
staticFallbackHandler.get('/test.txt', (_req, res) => {
res.end('this is test.txt');
});
const router = await createRouter({
logger: getVoidLogger(),
appPackageName: 'example-app',
staticFallbackHandler,
});
const app = express().use(router);
const response1 = await request(app).get('/static/main.txt');
expect(response1.status).toBe(200);
expect(response1.text).toBe('this is main.txt\n');
const response2 = await request(app).get('/static/test.txt');
expect(response2.status).toBe(200);
expect(response2.text).toBe('this is test.txt');
const response3 = await request(app).get('/static/missing.txt');
expect(response3.status).toBe(404);
});
});
+59
View File
@@ -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 { resolve as resolvePath } from 'path';
import { notFoundHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { injectEnvConfig } from '../lib/config';
export interface RouterOptions {
logger: Logger;
appPackageName: string;
staticFallbackHandler?: express.Handler;
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const appDistDir = resolvePackagePath(options.appPackageName, 'dist');
options.logger.info(`Serving static app content from ${appDistDir}`);
await injectEnvConfig({
env: process.env,
logger: options.logger,
staticDir: resolvePath(appDistDir, 'static'),
});
const router = Router();
// Use a separate router for static content so that a fallback can be provided by backend
const staticRouter = Router();
staticRouter.use(express.static(resolvePath(appDistDir, 'static')));
if (options.staticFallbackHandler) {
staticRouter.use(options.staticFallbackHandler);
}
staticRouter.use(notFoundHandler());
router.use('/static', staticRouter);
router.use(express.static(appDistDir));
router.get('/*', (_req, res) => {
res.sendFile(resolvePath(appDistDir, 'index.html'));
});
return router;
}
@@ -0,0 +1,46 @@
/*
* 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 { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
export interface ServerOptions {
port: number;
enableCors: boolean;
logger: Logger;
}
export async function startStandaloneServer(
options: ServerOptions,
): Promise<Server> {
const logger = options.logger.child({ service: 'app-backend' });
logger.debug('Starting application server...');
const router = await createRouter({
logger,
appPackageName: 'example-app',
});
const service = createServiceBuilder(module).addRouter('', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
module.hot?.accept();
@@ -14,16 +14,4 @@
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import { InfoCard, StructuredMetadataTable } from '@backstage/core';
import React, { FC } from 'react';
type Props = {
entity: Entity;
};
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
<InfoCard title="Information">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
export {};
+4 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-auth-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,10 +20,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"body-parser": "^1.19.0",
"compression": "^1.7.4",
"cookie-parser": "^1.4.5",
"cors": "^2.8.5",
@@ -50,7 +49,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/body-parser": "^1.19.0",
"@types/cookie-parser": "^1.4.2",
"@types/jwt-decode": "2.2.1",
@@ -15,13 +15,13 @@
*/
import Knex from 'knex';
import path from 'path';
import { utc } from 'moment';
import { resolvePackagePath } from '@backstage/backend-common';
import { AnyJWK, KeyStore, StoredKey } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-auth-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-auth-backend',
'migrations',
);
const TABLE = 'signing_keys';
+1 -3
View File
@@ -18,8 +18,6 @@ import express from 'express';
import { Logger } from 'winston';
import { TokenIssuer } from '../identity';
import { Config } from '@backstage/config';
import { OAuthProvider } from '../lib/OAuthProvider';
import { SamlAuthProvider } from './saml/provider';
export type OAuthProviderOptions = {
/**
@@ -174,7 +172,7 @@ export type AuthProviderFactory = (
envConfig: Config,
logger: Logger,
issuer: TokenIssuer,
) => OAuthProvider | SamlAuthProvider | undefined;
) => AuthProviderRouteHandlers | undefined;
export type AuthResponse<ProviderInfo> = {
providerInfo: ProviderInfo;
+2 -3
View File
@@ -17,7 +17,6 @@
import express from 'express';
import Router from 'express-promise-router';
import cookieParser from 'cookie-parser';
import bodyParser from 'body-parser';
import Knex from 'knex';
import { Logger } from 'winston';
import { createAuthProviderRouter } from '../providers';
@@ -53,8 +52,8 @@ export async function createRouter(
});
router.use(cookieParser());
router.use(bodyParser.urlencoded({ extended: false }));
router.use(bodyParser.json());
router.use(express.urlencoded({ extended: false }));
router.use(express.json());
const providersConfig = options.config.getConfig('auth.providers');
const providers = providersConfig.keys();
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,9 +22,9 @@
"mock-data:local": "./scripts/mock-data-local.sh"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -41,7 +41,7 @@
"yup": "^0.29.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/lodash": "^4.14.151",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
@@ -9,6 +9,8 @@ for URL in \
'playback-lib-component.yaml' \
'www-artist-component.yaml' \
'shuffle-api-component.yaml' \
'petstore-api.yaml' \
'streetlights-api.yaml' \
; do \
curl \
--location \
@@ -14,17 +14,16 @@
* limitations under the License.
*/
import { getVoidLogger } from '@backstage/backend-common';
import { getVoidLogger, resolvePackagePath } from '@backstage/backend-common';
import { makeValidator } from '@backstage/catalog-model';
import Knex from 'knex';
import path from 'path';
import { Logger } from 'winston';
import { CommonDatabase } from './CommonDatabase';
import { Database } from './types';
const migrationsDir = path.resolve(
require.resolve('@backstage/plugin-catalog-backend/package.json'),
'../migrations',
const migrationsDir = resolvePackagePath(
'@backstage/plugin-catalog-backend',
'migrations',
);
export type CreateDatabaseOptions = {
@@ -0,0 +1,217 @@
/*
* 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 { LocationSpec, Entity } from '@backstage/catalog-model';
import { CatalogRulesEnforcer } from './CatalogRules';
import { ConfigReader } from '@backstage/config';
const entity = {
user: {
kind: 'User',
} as Entity,
group: {
kind: 'Group',
} as Entity,
component: {
kind: 'component',
} as Entity,
location: {
kind: 'Location',
} as Entity,
};
const location: Record<string, LocationSpec> = {
x: {
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
},
y: {
type: 'github',
target: 'https://github.com/a/b/blob/master/y.yaml',
},
z: {
type: 'file',
target: '/root/z.yaml',
},
};
describe('CatalogRulesEnforcer', () => {
it('should deny by default', () => {
const enforcer = new CatalogRulesEnforcer([]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should deny all', () => {
const enforcer = new CatalogRulesEnforcer([{ allow: [] }]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = new CatalogRulesEnforcer([
{
allow: ['User', 'Group', 'Component', 'Location'].map(kind => ({
kind,
})),
},
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups from github', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'User' }, { kind: 'Component' }] },
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should allow groups from files', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'Group' }], locations: [{ type: 'file' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
});
it('should not be sensitive to kind case', () => {
const enforcer = new CatalogRulesEnforcer([
{ allow: [{ kind: 'group' }] },
{ allow: [{ kind: 'Component' }] },
]);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
describe('fromConfig', () => {
it('should allow components by default', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(new ConfigReader({}));
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(true);
});
it('should deny all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({ catalog: { rules: [] } }),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow all', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['User', 'Group'] }, { allow: ['Component'] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
});
it('should deny groups', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: { rules: [{ allow: ['User'] }, { allow: ['Component'] }] },
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should allow groups from a specific github location', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['user'] }],
locations: [
{
type: 'github',
target: 'https://github.com/a/b/blob/master/x.yaml',
rules: [
{
allow: ['Group'],
},
],
},
],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
it('should not care about location configuration in catalog.rules', () => {
const enforcer = CatalogRulesEnforcer.fromConfig(
new ConfigReader({
catalog: {
rules: [{ allow: ['Group'], locations: [{ type: 'github' }] }],
},
}),
);
expect(enforcer.isAllowed(entity.user, location.x)).toBe(false);
expect(enforcer.isAllowed(entity.group, location.x)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.y)).toBe(true);
expect(enforcer.isAllowed(entity.group, location.z)).toBe(true);
expect(enforcer.isAllowed(entity.component, location.z)).toBe(false);
expect(enforcer.isAllowed(entity.location, location.z)).toBe(false);
});
});
});
@@ -0,0 +1,177 @@
/*
* 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 { Config } from '@backstage/config';
import { LocationSpec, Entity } from '@backstage/catalog-model';
/**
* A structure for matching entities to a given rule.
*/
type EntityMatcher = {
kind: string;
};
/**
* A structure for matching locations to a given rule.
*/
type LocationMatcher = {
target?: string;
type: string;
};
/**
* Rules to apply to catalog entities
*
* An undefined list of matchers means match all, an empty list of matchers means match none
*/
type CatalogRule = {
allow: EntityMatcher[];
locations?: LocationMatcher[];
};
export class CatalogRulesEnforcer {
/**
* Default rules used by the catalog.
*
* Denies any location from specifying user or group entities.
*/
static readonly defaultRules: CatalogRule[] = [
{
allow: ['Component', 'API', 'Location'].map(kind => ({ kind })),
},
];
/**
* Loads catalog rules from config.
*
* This reads `catalog.rules` and defaults to the default rules if no value is present.
* The value of the config should be a list of config objects, each with a single `allow`
* field which in turn is a list of entity kinds to allow.
*
* If there is no matching rule to allow an ingested entity, it will be rejected by the catalog.
*
* It also reads in rules from `catalog.locations`, where each location can have a list
* of rules for that specific location, specified in a `rules` field.
*
* For example:
*
* ```yaml
* catalog:
* rules:
* - allow: [Component, API]
*
* locations:
* - type: github
* target: https://github.com/org/repo/blob/master/users.yaml
* rules:
* - allow: [User, Group]
* - type: github
* target: https://github.com/org/repo/blob/master/systems.yaml
* rules:
* - allow: [System]
* ```
*/
static fromConfig(config: Config) {
const rules = new Array<CatalogRule>();
if (config.has('catalog.rules')) {
const globalRules = config.getConfigArray('catalog.rules').map(sub => ({
allow: sub.getStringArray('allow').map(kind => ({ kind })),
}));
rules.push(...globalRules);
} else {
rules.push(...CatalogRulesEnforcer.defaultRules);
}
if (config.has('catalog.locations')) {
const locationRules = config
.getConfigArray('catalog.locations')
.flatMap(locConf => {
if (!locConf.has('rules')) {
return [];
}
const type = locConf.getString('type');
const target = locConf.getString('target');
return locConf.getConfigArray('rules').map(ruleConf => ({
allow: ruleConf.getStringArray('allow').map(kind => ({ kind })),
locations: [{ type, target }],
}));
});
rules.push(...locationRules);
}
return new CatalogRulesEnforcer(rules);
}
constructor(private readonly rules: CatalogRule[]) {}
/**
* Checks wether a specific entity/location combination is allowed
* according to the configured rules.
*/
isAllowed(entity: Entity, location: LocationSpec) {
for (const rule of this.rules) {
if (!this.matchLocation(location, rule.locations)) {
continue;
}
if (this.matchEntity(entity, rule.allow)) {
return true;
}
}
return false;
}
private matchLocation(
location: LocationSpec,
matchers?: LocationMatcher[],
): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (matcher.type !== location.type) {
continue;
}
if (matcher.target && matcher.target !== location.target) {
continue;
}
return true;
}
return false;
}
private matchEntity(entity: Entity, matchers?: EntityMatcher[]): boolean {
if (!matchers) {
return true;
}
for (const matcher of matchers) {
if (entity.kind.toLowerCase() !== matcher.kind.toLowerCase()) {
continue;
}
return true;
}
return false;
}
}
@@ -47,6 +47,7 @@ import {
} from './processors/types';
import { YamlProcessor } from './processors/YamlProcessor';
import { LocationReader, ReadLocationResult } from './types';
import { CatalogRulesEnforcer } from './CatalogRules';
// The max amount of nesting depth of generated work items
const MAX_DEPTH = 10;
@@ -63,6 +64,7 @@ type Options = {
export class LocationReaders implements LocationReader {
private readonly logger: Logger;
private readonly processors: LocationProcessor[];
private readonly rulesEnforcer: CatalogRulesEnforcer;
static defaultProcessors(options: {
config?: Config;
@@ -76,11 +78,11 @@ export class LocationReaders implements LocationReader {
StaticLocationProcessor.fromConfig(config),
new FileReaderProcessor(),
new GithubReaderProcessor(),
new GithubApiReaderProcessor(),
new GitlabApiReaderProcessor(),
new GithubApiReaderProcessor(config),
new GitlabApiReaderProcessor(config),
new GitlabReaderProcessor(),
new BitbucketApiReaderProcessor(),
new AzureApiReaderProcessor(),
new BitbucketApiReaderProcessor(config),
new AzureApiReaderProcessor(config),
new UrlReaderProcessor(),
new YamlProcessor(),
new EntityPolicyProcessor(entityPolicy),
@@ -96,6 +98,9 @@ export class LocationReaders implements LocationReader {
}: Options) {
this.logger = logger;
this.processors = processors;
this.rulesEnforcer = config
? CatalogRulesEnforcer.fromConfig(config)
: new CatalogRulesEnforcer(CatalogRulesEnforcer.defaultRules);
}
async read(location: LocationSpec): Promise<ReadLocationResult> {
@@ -112,11 +117,20 @@ export class LocationReaders implements LocationReader {
} else if (item.type === 'data') {
await this.handleData(item, emit);
} else if (item.type === 'entity') {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
if (this.rulesEnforcer.isAllowed(item.entity, item.location)) {
const entity = await this.handleEntity(item, emit);
output.entities.push({
entity,
location: item.location,
});
} else {
output.errors.push({
location: item.location,
error: new Error(
`Entity of kind ${item.entity.kind} is not allowed from location ${item.location.target}:${item.location.type}`,
),
});
}
} else if (item.type === 'error') {
await this.handleError(item, emit);
output.errors.push({
@@ -15,10 +15,27 @@
*/
import { AzureApiReaderProcessor } from './AzureApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('AzureApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
azureApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new AzureApiReaderProcessor();
const processor = new AzureApiReaderProcessor(createConfig(undefined));
const tests = [
{
target:
@@ -72,13 +89,26 @@ describe('AzureApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.azureApi.privateToken' in '', got empty-string, wanted string",
},
{
token: undefined,
expect: {
headers: {},
},
},
];
for (const test of tests) {
process.env.AZURE_PRIVATE_TOKEN = test.token;
const processor = new AzureApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new AzureApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new AzureApiReaderProcessor(createConfig(test.token));
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class AzureApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.AZURE_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.azureApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
@@ -53,7 +60,8 @@ export class AzureApiReaderProcessor implements LocationProcessor {
const response = await fetch(url.toString(), this.getRequestOptions());
if (response.ok) {
// for private repos when PAT is not valid, Azure API returns a http status code 203 with sign in page html
if (response.ok && response.status !== 203) {
const data = await response.buffer();
emit(result.data(location, data));
} else {
@@ -76,7 +84,6 @@ export class AzureApiReaderProcessor implements LocationProcessor {
// Converts
// from: https://dev.azure.com/{organization}/{project}/_git/reponame?path={path}&version=GB{commitOrBranch}&_a=contents
// to: https://dev.azure.com/{organization}/{project}/_apis/sourceProviders/{providerName}/filecontents?repository={repository}&commitOrBranch={commitOrBranch}&path={path}&api-version=6.0-preview.1
buildRawUrl(target: string): URL {
try {
const url = new URL(target);
@@ -15,10 +15,33 @@
*/
import { BitbucketApiReaderProcessor } from './BitbucketApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('BitbucketApiReaderProcessor', () => {
const createConfig = (
username: string | undefined,
appPassword: string | undefined,
) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
bitbucketApi: {
username: username,
appPassword: appPassword,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new BitbucketApiReaderProcessor();
const processor = new BitbucketApiReaderProcessor(
createConfig(undefined, undefined),
);
const tests = [
{
@@ -66,6 +89,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'only-user-provided',
@@ -73,6 +98,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.appPassword' in '', got empty-string, wanted string",
},
{
username: '',
@@ -80,6 +107,8 @@ describe('BitbucketApiReaderProcessor', () => {
expect: {
headers: {},
},
err:
"Invalid type in config for key 'catalog.processors.bitbucketApi.username' in '', got empty-string, wanted string",
},
{
username: 'some-user',
@@ -90,13 +119,43 @@ describe('BitbucketApiReaderProcessor', () => {
},
},
},
{
username: undefined,
password: undefined,
expect: {
headers: {},
},
},
{
username: 'only-user-provided',
password: undefined,
expect: {
headers: {},
},
},
{
username: undefined,
password: 'only-password-provided',
expect: {
headers: {},
},
},
];
for (const test of tests) {
process.env.BITBUCKET_USERNAME = test.username;
process.env.BITBUCKET_APP_PASSWORD = test.password;
const processor = new BitbucketApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() =>
new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
),
).toThrowError(test.err);
} else {
const processor = new BitbucketApiReaderProcessor(
createConfig(test.username, test.password),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,10 +18,20 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class BitbucketApiReaderProcessor implements LocationProcessor {
private username: string = process.env.BITBUCKET_USERNAME || '';
private password: string = process.env.BITBUCKET_APP_PASSWORD || '';
private username: string;
private password: string;
constructor(config: Config) {
this.username =
config.getOptionalString('catalog.processors.bitbucketApi.username') ??
'';
this.password =
config.getOptionalString('catalog.processors.bitbucketApi.appPassword') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {};
@@ -15,10 +15,27 @@
*/
import { GithubApiReaderProcessor } from './GithubApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GithubApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
githubApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GithubApiReaderProcessor();
const processor = new GithubApiReaderProcessor(createConfig(undefined));
const tests = [
{
@@ -78,6 +95,16 @@ describe('GithubApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.githubApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
},
},
},
{
token: undefined,
expect: {
headers: {
Accept: 'application/vnd.github.v3.raw',
@@ -87,9 +114,16 @@ describe('GithubApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITHUB_PRIVATE_TOKEN = test.token;
const processor = new GithubApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GithubApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GithubApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GithubApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITHUB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.githubApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = {
@@ -15,10 +15,27 @@
*/
import { GitlabApiReaderProcessor } from './GitlabApiReaderProcessor';
import { ConfigReader } from '@backstage/config';
describe('GitlabApiReaderProcessor', () => {
const createConfig = (token: string | undefined) =>
ConfigReader.fromConfigs([
{
context: '',
data: {
catalog: {
processors: {
gitlabApi: {
privateToken: token,
},
},
},
},
},
]);
it('should build raw api', () => {
const processor = new GitlabApiReaderProcessor();
const processor = new GitlabApiReaderProcessor(createConfig(undefined));
const tests = [
{
@@ -83,6 +100,16 @@ describe('GitlabApiReaderProcessor', () => {
},
{
token: '',
err:
"Invalid type in config for key 'catalog.processors.gitlabApi.privateToken' in '', got empty-string, wanted string",
expect: {
headers: {
'PRIVATE-TOKEN': '',
},
},
},
{
token: undefined,
expect: {
headers: {
'PRIVATE-TOKEN': '',
@@ -92,9 +119,16 @@ describe('GitlabApiReaderProcessor', () => {
];
for (const test of tests) {
process.env.GITLAB_PRIVATE_TOKEN = test.token;
const processor = new GitlabApiReaderProcessor();
expect(processor.getRequestOptions()).toEqual(test.expect);
if (test.err) {
expect(
() => new GitlabApiReaderProcessor(createConfig(test.token)),
).toThrowError(test.err);
} else {
const processor = new GitlabApiReaderProcessor(
createConfig(test.token),
);
expect(processor.getRequestOptions()).toEqual(test.expect);
}
}
});
});
@@ -18,9 +18,16 @@ import { LocationSpec } from '@backstage/catalog-model';
import fetch, { RequestInit, HeadersInit } from 'node-fetch';
import * as result from './results';
import { LocationProcessor, LocationProcessorEmit } from './types';
import { Config } from '@backstage/config';
export class GitlabApiReaderProcessor implements LocationProcessor {
private privateToken: string = process.env.GITLAB_PRIVATE_TOKEN || '';
private privateToken: string;
constructor(config: Config) {
this.privateToken =
config.getOptionalString('catalog.processors.gitlabApi.privateToken') ??
'';
}
getRequestOptions(): RequestInit {
const headers: HeadersInit = { 'PRIVATE-TOKEN': '' };
+15 -14
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-catalog",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,30 +21,31 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/plugin-api-docs": "^0.1.1-alpha.20",
"@backstage/plugin-github-actions": "^0.1.1-alpha.20",
"@backstage/plugin-jenkins": "^0.1.1-alpha.20",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.20",
"@backstage/plugin-sentry": "^0.1.1-alpha.20",
"@backstage/plugin-techdocs": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-api-docs": "^0.1.1-alpha.21",
"@backstage/plugin-github-actions": "^0.1.1-alpha.21",
"@backstage/plugin-jenkins": "^0.1.1-alpha.21",
"@backstage/plugin-scaffolder": "^0.1.1-alpha.21",
"@backstage/plugin-techdocs": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"moment": "^2.26.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-helmet": "6.1.0",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3",
"swr": "^0.3.0"
"swr": "^0.3.0",
"@types/react": "^16.9"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/react-hooks": "^3.3.0",
@@ -0,0 +1,46 @@
/*
* 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 { render } from '@testing-library/react';
import { AboutCard } from './AboutCard';
describe('<AboutCard />', () => {
it('renders info and "view source" link', () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/spotify/backstage/blob/master/software.yaml',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const { getByText } = render(<AboutCard entity={entity} />);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://github.com/spotify/backstage/blob/master/software.yaml',
);
});
});
@@ -0,0 +1,183 @@
/*
* 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 {
Grid,
Typography,
makeStyles,
Chip,
IconButton,
Card,
CardContent,
CardHeader,
Divider,
} from '@material-ui/core';
import { Entity } from '@backstage/catalog-model';
import GitHubIcon from '@material-ui/icons/GitHub';
import { IconLinkVertical } from './IconLinkVertical';
import EditIcon from '@material-ui/icons/Edit';
import DocsIcon from '@material-ui/icons/Description';
const useStyles = makeStyles(theme => ({
links: {
margin: theme.spacing(2, 0),
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns: 'min-content',
gridGap: theme.spacing(3),
},
label: {
color: theme.palette.text.secondary,
textTransform: 'uppercase',
fontSize: '10px',
fontWeight: 'bold',
letterSpacing: 0.5,
overflow: 'hidden',
whiteSpace: 'nowrap',
},
value: {
fontWeight: 'bold',
overflow: 'hidden',
lineHeight: '24px',
wordBreak: 'break-word',
},
description: {
wordBreak: 'break-word',
},
}));
const iconMap: Record<string, React.ReactNode> = {
github: <GitHubIcon />,
};
type CodeLinkInfo = { icon?: React.ReactNode; href?: string };
function getCodeLinkInfo(entity: Entity): CodeLinkInfo {
const location =
entity?.metadata?.annotations?.['backstage.io/managed-by-location'];
if (location) {
// split by first `:`
// e.g. "github:https://github.com/spotify/backstage/blob/master/software.yaml"
const [type, target] = location.split(/:(.+)/);
return { icon: iconMap[type], href: target };
}
return {};
}
type AboutCardProps = {
entity: Entity;
};
export function AboutCard({ entity }: AboutCardProps) {
const classes = useStyles();
const codeLink = getCodeLinkInfo(entity);
return (
<Card>
<CardHeader
title="About"
action={
<IconButton href={codeLink.href || '#'} aria-label="Edit">
<EditIcon />
</IconButton>
}
subheader={
<nav className={classes.links}>
<IconLinkVertical label="View Source" {...codeLink} />
<IconLinkVertical
label="View Techdocs"
icon={<DocsIcon />}
href={`/docs/${''}`}
/>
</nav>
}
/>
<Divider />
<CardContent>
<Grid container>
<AboutField label="Description" gridSizes={{ xs: 12 }}>
<Typography
variant="body2"
paragraph
className={classes.description}
>
{entity?.metadata?.description || 'No description'}
</Typography>
</AboutField>
<AboutField
label="Owner"
value={entity?.spec?.owner as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Type"
value={entity?.spec?.type as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Lifecycle"
value={entity?.spec?.lifecycle as string}
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
/>
<AboutField
label="Tags"
value="No Tags"
gridSizes={{ xs: 12, sm: 6, lg: 4 }}
>
{(entity?.metadata?.tags || []).map(t => (
<Chip key={t} size="small" label={t} />
))}
</AboutField>
</Grid>
</CardContent>
</Card>
);
}
function AboutField({
label,
value,
gridSizes,
children,
}: {
label: string;
value?: string;
gridSizes?: Record<string, number>;
children?: React.ReactNode;
}) {
const classes = useStyles();
// Content is either children or a string prop `value`
const content = React.Children.count(children) ? (
children
) : (
<Typography variant="body2" className={classes.value}>
{value || `unknown`}
</Typography>
);
return (
<Grid item {...gridSizes}>
<Typography variant="subtitle2" className={classes.label}>
{label}
</Typography>
{content}
</Grid>
);
}
@@ -0,0 +1,53 @@
/*
* 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 * as React from 'react';
import { makeStyles, Link } from '@material-ui/core';
import LinkIcon from '@material-ui/icons/Link';
export type IconLinkVerticalProps = {
icon?: React.ReactNode;
href?: string;
label: string;
};
const useIconStyles = makeStyles({
link: {
display: 'grid',
justifyItems: 'center',
gridGap: 4,
textAlign: 'center',
},
label: {
fontSize: '0.7rem',
textTransform: 'uppercase',
fontWeight: 600,
letterSpacing: 1.2,
},
});
export function IconLinkVertical({
icon = <LinkIcon />,
href = '#',
...props
}: IconLinkVerticalProps) {
const classes = useIconStyles();
return (
<Link className={classes.link} href={href} {...props}>
{icon}
<span className={classes.label}>{props.label}</span>
</Link>
);
}
@@ -13,4 +13,5 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Widget, RecentWorkflowRunsCard } from './Widget';
export { IconLinkVertical } from './IconLinkVertical';
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { WorkflowRunDetailsPage } from './WorkflowRunDetailsPage';
export { AboutCard } from './AboutCard';
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { CatalogPage } from './CatalogPage';
@@ -75,12 +75,7 @@ const columns: TableColumn<Entity>[] = [
<>
{entity.metadata.tags &&
entity.metadata.tags.map(t => (
<Chip
key={t}
label={t}
color="secondary"
style={{ marginBottom: '0px' }}
/>
<Chip key={t} label={t} style={{ marginBottom: '0px' }} />
))}
</>
),
@@ -1,100 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
jest.mock('react-router-dom', () => {
const actual = jest.requireActual('react-router-dom');
const mockNavigate = jest.fn();
return {
...actual,
useNavigate: jest.fn(() => mockNavigate),
useParams: jest.fn(),
};
});
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { wrapInTestApp } from '@backstage/test-utils';
import { render, waitFor } from '@testing-library/react';
import * as React from 'react';
import { CatalogApi, catalogApiRef } from '../../api/types';
import { EntityPage, getPageTheme } from './EntityPage';
const {
useParams,
useNavigate,
}: { useParams: jest.Mock; useNavigate: () => jest.Mock } = jest.requireMock(
'react-router-dom',
);
const errorApi = { post: () => {} };
describe('EntityPage', () => {
it('should redirect to catalog page when name is not provided', async () => {
useParams.mockReturnValue({
kind: 'Component',
optionalNamespaceAndName: '',
});
render(
wrapInTestApp(
<ApiProvider
apis={ApiRegistry.from([
[errorApiRef, errorApi],
[
catalogApiRef,
({
async getEntityByName() {},
} as Partial<CatalogApi>) as CatalogApi,
],
])}
>
<EntityPage />
</ApiProvider>,
),
);
await waitFor(() => expect(useNavigate()).toHaveBeenCalledWith('/catalog'));
});
});
describe('getPageTheme', () => {
const defaultPageTheme = getPageTheme();
it.each(['service', 'app', 'library', 'tool', 'documentation', 'website'])(
'should select right theme for predefined type: %p ̰ ',
type => {
const theme = getPageTheme(({
spec: {
type,
},
} as any) as Entity);
expect(theme).toBeDefined();
expect(theme).not.toBe(defaultPageTheme);
},
);
it('should select default theme for unknown/unspecified types', () => {
const theme1 = getPageTheme(({
spec: {
type: 'unknown-type',
},
} as any) as Entity);
const theme2 = getPageTheme(({
spec: {},
} as any) as Entity);
expect(theme1).toBe(defaultPageTheme);
expect(theme2).toBe(defaultPageTheme);
});
});
@@ -1,231 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
Header,
HeaderLabel,
Page,
pageTheme,
PageTheme,
Progress,
useApi,
HeaderTabs,
} from '@backstage/core';
import { Box } from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { FC, useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { EntityPageDocs } from '../EntityPageDocs/EntityDocsPage';
import { EntityPageApi } from '../EntityPageApi/EntityPageApi';
import { EntityPageOverview } from '../EntityPageOverview/EntityPageOverview';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
const REDIRECT_DELAY = 1000;
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle: FC<{ title: string; entity: Entity | undefined }> = ({
entity,
title,
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
export const EntityPage: FC<{}> = () => {
const {
optionalNamespaceAndName,
kind,
selectedTabId = 'overview',
} = useParams() as {
optionalNamespaceAndName: string;
kind: string;
selectedTabId: string;
};
const navigate = useNavigate();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (!error && !loading && !entity) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
}, [errorApi, navigate, error, loading, entity]);
if (!name) {
navigate('/catalog');
return null;
}
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
// TODO - Replace with proper tabs implementation
const tabs = [
{
id: 'overview',
label: 'Overview',
content: (e: Entity) => <EntityPageOverview entity={e} />,
},
{
id: 'ci',
label: 'CI/CD',
},
{
id: 'tests',
label: 'Tests',
},
{
id: 'api',
label: 'API',
show: (e: Entity) => !!e?.spec?.implementsApis,
content: (e: Entity) => <EntityPageApi entity={e} />,
},
{
id: 'monitoring',
label: 'Monitoring',
},
{
id: 'quality',
label: 'Quality',
},
{
id: 'docs',
label: 'Docs',
show: (e: Entity) =>
!!e.metadata.annotations?.['backstage.io/techdocs-ref'],
content: (e: Entity) => <EntityPageDocs entity={e} />,
},
];
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity,
);
const selectedTab = tabs.find(tab => tab.id === selectedTabId);
const filteredHeaderTabs = entity
? tabs.filter(tab => (tab.show ? tab.show(entity) : true))
: [];
return (
<Page theme={getPageTheme(entity)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<HeaderTabs
tabs={filteredHeaderTabs}
onChange={idx => {
navigate(
`/catalog/${kind}/${optionalNamespaceAndName}/${filteredHeaderTabs[idx].id}`,
);
}}
selectedIndex={filteredHeaderTabs.findIndex(
tab => tab.id === selectedTabId,
)}
/>
{selectedTab && selectedTab.content
? selectedTab.content(entity)
: null}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
</Page>
);
};
@@ -14,19 +14,23 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { render } from '@testing-library/react';
import React from 'react';
import { EntityMetadataCard } from './EntityMetadataCard';
import { Content } from '@backstage/core';
import { LatestWorkflowsForBranchCard } from '@backstage/plugin-github-actions';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
describe('EntityMetadataCard component', () => {
it('should display entity name if provided', async () => {
const testEntity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'test' },
};
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
});
export const EntityPageCi: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
<Grid item sm={12}>
<LatestWorkflowsForBranchCard entity={entity} branch="master" />
</Grid>
)}
</Grid>
</Content>
);
};
@@ -0,0 +1,142 @@
/*
* 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, useContext } from 'react';
import { useParams, useNavigate } from 'react-router';
import { EntityContext } from '../../hooks/useEntity';
import {
pageTheme,
PageTheme,
Page,
Header,
HeaderLabel,
Content,
Progress,
} from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
import { FavouriteEntity } from '../FavouriteEntity/FavouriteEntity';
import { Box } from '@material-ui/core';
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
import { Alert } from '@material-ui/lab';
import { Tabbed } from './Tabbed';
const getPageTheme = (entity?: Entity): PageTheme => {
const themeKey = entity?.spec?.type?.toString() ?? 'home';
return pageTheme[themeKey] ?? pageTheme.home;
};
const EntityPageTitle = ({
entity,
title,
}: {
title: string;
entity: Entity | undefined;
}) => (
<Box display="inline-flex" alignItems="center" height="1em">
{title}
{entity && <FavouriteEntity entity={entity} />}
</Box>
);
function headerProps(
kind: string,
namespace: string | undefined,
name: string,
entity: Entity | undefined,
): { headerTitle: string; headerType: string } {
return {
headerTitle: `${name}${namespace ? ` in ${namespace}` : ''}`,
headerType: (() => {
let t = kind.toLowerCase();
if (entity && entity.spec && 'type' in entity.spec) {
t += ' — ';
t += (entity.spec as { type: string }).type.toLowerCase();
}
return t;
})(),
};
}
export const EntityPageLayout = ({
children,
}: {
children?: React.ReactNode;
}) => {
const { optionalNamespaceAndName, kind } = useParams() as {
optionalNamespaceAndName: string;
kind: string;
};
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const { entity, loading, error } = useContext(EntityContext);
const { headerTitle, headerType } = headerProps(
kind,
namespace,
name,
entity!,
);
const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false);
const navigate = useNavigate();
const cleanUpAfterRemoval = async () => {
setConfirmationDialogOpen(false);
navigate('/');
};
const showRemovalDialog = () => setConfirmationDialogOpen(true);
return (
<Page theme={getPageTheme(entity!)}>
<Header
title={<EntityPageTitle title={headerTitle} entity={entity!} />}
pageTitleOverride={headerTitle}
type={headerType}
>
{entity && (
<>
<HeaderLabel
label="Owner"
value={entity.spec?.owner || 'unknown'}
/>
<HeaderLabel
label="Lifecycle"
value={entity.spec?.lifecycle || 'unknown'}
/>
<EntityContextMenu onUnregisterEntity={showRemovalDialog} />
</>
)}
</Header>
{loading && <Progress />}
{entity && <Tabbed.Layout>{children}</Tabbed.Layout>}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity!}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</Page>
);
};
EntityPageLayout.Content = Tabbed.Content;
@@ -0,0 +1,198 @@
/*
* 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 { Tabbed } from './Tabbed';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { Routes, Route } from 'react-router';
describe('Tabbed layout', () => {
it('renders simplest case', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
</Tabbed.Layout>,
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
});
it('throws if any other component is a child of Tabbed.Layout', async () => {
await expect(
renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="*"
element={<div>tabbed-test-content</div>}
/>
<div>This will cause app to throw</div>
</Tabbed.Layout>,
),
).rejects.toThrow(/This component only accepts/);
});
it('navigates when user clicks different tab', async () => {
const rendered = await renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>
}
/>
</Routes>,
);
const secondTab = rendered.queryAllByRole('tab')[1];
act(() => {
fireEvent.click(secondTab);
});
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
describe('correctly delegates nested links', () => {
const renderRoute = (route: string) =>
renderInTestApp(
<Routes>
<Route
path="/*"
element={
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path/*"
element={
<div>
tabbed-test-content-2
<Routes>
<Route
path="/nested"
element={<div>tabbed-test-nested-content-2</div>}
/>
</Routes>
</div>
}
/>
</Tabbed.Layout>
}
/>
</Routes>,
{ routeEntries: [route] },
);
it('works for nested content', async () => {
const rendered = await renderRoute('/some-other-path/nested');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).toBeInTheDocument();
});
it('works for non-nested content', async () => {
const rendered = await renderRoute('/some-other-path/');
expect(
rendered.queryByText('tabbed-test-content'),
).not.toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-nested-content-2'),
).not.toBeInTheDocument();
});
});
it('shows only one tab contents at a time', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/some-other-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content')).not.toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(rendered.queryByText('tabbed-test-content-2')).toBeInTheDocument();
});
it('redirects to the top level when no route is matching the url', async () => {
const rendered = await renderInTestApp(
<Tabbed.Layout>
<Tabbed.Content
title="tabbed-test-title"
path="/"
element={<div>tabbed-test-content</div>}
/>
<Tabbed.Content
title="tabbed-test-title-2"
path="/some-other-path"
element={<div>tabbed-test-content-2</div>}
/>
</Tabbed.Layout>,
{ routeEntries: ['/non-existing-path'] },
);
expect(rendered.getByText('tabbed-test-title')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-content')).toBeInTheDocument();
expect(rendered.getByText('tabbed-test-title-2')).toBeInTheDocument();
expect(
rendered.queryByText('tabbed-test-content-2'),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,127 @@
/*
* 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 {
useParams,
useNavigate,
PartialRouteObject,
matchRoutes,
RouteObject,
useRoutes,
Navigate,
RouteMatch,
} from 'react-router';
import { Tab, HeaderTabs, Content } from '@backstage/core';
import { Helmet } from 'react-helmet';
const getSelectedIndexOrDefault = (
matchedRoute: RouteMatch,
tabs: Tab[],
defaultIndex = 0,
) => {
if (!matchedRoute) return defaultIndex;
const tabIndex = tabs.findIndex(t => t.id === matchedRoute.route.path);
return ~tabIndex ? tabIndex : defaultIndex;
};
/**
* Compound component, which allows you to define layout
* for EntityPage using Tabs as a subnavigation mechanism
* Constists of 2 parts: Tabbed.Layout and Tabbed.Content.
* Takes care of: tabs, routes, document titles, spacing around content
*
* @example
* ```jsx
* <Tabbed.Layout>
* <Tabbed.Content
* title="Example tab"
* route="/example/*"
* element={<div>This is rendered under /example/anything-here route</div>}
* />
* </TabbedLayout>
* ```
*/
export const Tabbed = {
Layout: ({ children }: { children: React.ReactNode }) => {
const routes: PartialRouteObject[] = [];
const tabs: Tab[] = [];
const params = useParams();
const navigate = useNavigate();
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) {
// Skip conditionals resolved to falses/nulls/undefineds etc
return;
}
if (child.type !== Tabbed.Content) {
throw new Error(
'This component only accepts Content elements as direct children. Check the code of the EntityPage.',
);
}
const pathAndId = (child as JSX.Element).props.path;
// Child here must be then always a functional component without any wrappers
tabs.push({
id: pathAndId,
label: (child as JSX.Element).props.title,
});
routes.push({
path: pathAndId,
element: child.props.element,
});
});
// Add catch-all for incorrect sub-routes
if ((routes?.[0]?.path ?? '') !== '')
routes.push({
path: '/*',
element: <Navigate to={routes[0].path!} />,
});
const [matchedRoute] =
matchRoutes(routes as RouteObject[], `/${params['*']}`) ?? [];
const selectedIndex = getSelectedIndexOrDefault(matchedRoute, tabs);
const currentTab = tabs[selectedIndex];
const title = currentTab?.label;
const onTabChange = (index: number) =>
// Remove trailing /*
// And remove leading / for relative navigation
// Note! route resolves relative to the position in the React tree,
// not relative to current location
navigate(tabs[index].id.replace(/\/\*$/, '').replace(/^\//, ''));
const currentRouteElement = useRoutes(routes);
if (!currentTab) return null;
return (
<>
<HeaderTabs
tabs={tabs}
selectedIndex={selectedIndex}
onChange={onTabChange}
/>
<Content>
<Helmet title={title} />
{currentRouteElement}
</Content>
</>
);
},
Content: (_props: { path: string; title: string; element: JSX.Element }) =>
null,
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Tabbed } from './Tabbed';
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityPageLayout } from './EntityPageLayout';
@@ -14,24 +14,24 @@
* limitations under the License.
*/
// TODO(shmidt-i): move to the app
import { Entity } from '@backstage/catalog-model';
import { Content } from '@backstage/core';
import { SentryIssuesWidget } from '@backstage/plugin-sentry';
import { Widget as GithubActionsWidget } from '@backstage/plugin-github-actions';
import { LatestWorkflowRunCard } from '@backstage/plugin-github-actions';
import {
JenkinsBuildsWidget,
JenkinsLastBuildWidget,
} from '@backstage/plugin-jenkins';
import { Grid } from '@material-ui/core';
import React, { FC } from 'react';
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
import { AboutCard } from '../AboutCard';
export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
return (
<Content>
<Grid container spacing={3}>
<Grid item sm={4}>
<EntityMetadataCard entity={entity} />
<AboutCard entity={entity} />
</Grid>
{entity.metadata?.annotations?.[
'backstage.io/jenkins-github-folder'
@@ -49,15 +49,9 @@ export const EntityPageOverview: FC<{ entity: Entity }> = ({ entity }) => {
)}
{entity.metadata?.annotations?.['backstage.io/github-actions-id'] && (
<Grid item sm={3}>
<GithubActionsWidget entity={entity} branch="master" />
<LatestWorkflowRunCard entity={entity} branch="master" />
</Grid>
)}
<Grid item sm={8}>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
);
@@ -0,0 +1,27 @@
/*
* 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, { ReactNode } from 'react';
import { useEntityFromUrl, EntityContext } from '../../hooks/useEntity';
export const EntityProvider = ({ children }: { children: ReactNode }) => {
const { entity, loading, error } = useEntityFromUrl();
return (
<EntityContext.Provider value={{ entity, loading, error }}>
{children}
</EntityContext.Provider>
);
};
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { EntityProvider } from './EntityProvider';
+73
View File
@@ -0,0 +1,73 @@
/*
* 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, { ComponentType } from 'react';
import { CatalogPage } from './CatalogPage';
import { EntityPageLayout } from './EntityPageLayout';
import { Route, Routes } from 'react-router';
import { entityRoute, rootRoute } from '../routes';
import { Content } from '@backstage/core';
import { Typography, Link } from '@material-ui/core';
import { EntityProvider } from './EntityProvider';
import { useEntity } from '../hooks/useEntity';
const DefaultEntityPage = () => (
<EntityPageLayout>
<EntityPageLayout.Content
path="/"
title="Overview"
element={
<Content>
<Typography variant="h2">This is default entity page. </Typography>
<Typography variant="body1">
To override this component with your custom implementation, read
docs on{' '}
<Link target="_blank" href="https://backstage.io/docs">
backstage.io/docs
</Link>
</Typography>
</Content>
}
/>
</EntityPageLayout>
);
const EntityPageSwitch = ({ EntityPage }: { EntityPage: ComponentType }) => {
const { entity } = useEntity();
// Loading and error states
if (!entity) return <EntityPageLayout />;
// Otherwise EntityPage provided from the App
// Note that EntityPage will include EntityPageLayout already
return <EntityPage />;
};
export const Router = ({
EntityPage = DefaultEntityPage,
}: {
EntityPage?: ComponentType;
}) => (
<Routes>
<Route path={`/${rootRoute.path}`} element={<CatalogPage />} />
<Route
path={`/${entityRoute.path}`}
element={
<EntityProvider>
<EntityPageSwitch EntityPage={EntityPage} />
</EntityProvider>
}
/>
</Routes>
);
+72
View File
@@ -0,0 +1,72 @@
/*
* 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 { useEffect, createContext, useContext } from 'react';
import { useNavigate, useParams } from 'react-router';
import { useApi, errorApiRef } from '@backstage/core';
import { catalogApiRef } from '../api/types';
import { useAsync } from 'react-use';
import { Entity } from '@backstage/catalog-model';
const REDIRECT_DELAY = 2000;
type EntityLoadingStatus = {
entity?: Entity;
loading: boolean;
error?: Error;
};
export const EntityContext = createContext<EntityLoadingStatus>({
entity: undefined,
loading: true,
error: undefined,
});
export const useEntityFromUrl = (): EntityLoadingStatus => {
const { optionalNamespaceAndName, kind } = useParams();
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
const navigate = useNavigate();
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: entity, error, loading } = useAsync(
() => catalogApi.getEntityByName({ kind, namespace, name }),
[catalogApi, kind, namespace, name],
);
useEffect(() => {
if (error || (!loading && !entity)) {
errorApi.post(new Error('Entity not found!'));
setTimeout(() => {
navigate('/');
}, REDIRECT_DELAY);
}
if (!name) {
errorApi.post(new Error('No name provided!'));
navigate('/');
}
}, [errorApi, navigate, error, loading, entity, name]);
return { entity, loading, error };
};
/**
* Always going to return an entity, or throw an error if not a descendant of a EntityProvider.
*/
export const useEntity = () => {
const { entity } = useContext<{ entity: Entity }>(EntityContext as any);
return { entity };
};
+4
View File
@@ -19,3 +19,7 @@ export * from './api/CatalogClient';
export * from './api/types';
export * from './routes';
export { useEntityCompoundName } from './components/useEntityCompoundName';
export { Router } from './components/Router';
export { useEntity } from './hooks/useEntity';
export { AboutCard } from './components/AboutCard';
export { EntityPageLayout } from './components/EntityPageLayout';
-8
View File
@@ -15,15 +15,7 @@
*/
import { createPlugin } from '@backstage/core';
import { CatalogPage } from './components/CatalogPage/CatalogPage';
import { EntityPage } from './components/EntityPage/EntityPage';
import { entityRoute, rootRoute, entityRouteDefault } from './routes';
export const plugin = createPlugin({
id: 'catalog',
register({ router }) {
router.addRoute(rootRoute, CatalogPage);
router.addRoute(entityRoute, EntityPage);
router.addRoute(entityRouteDefault, EntityPage);
},
});
+2 -7
View File
@@ -20,16 +20,11 @@ const NoIcon = () => null;
export const rootRoute = createRouteRef({
icon: NoIcon,
path: '/',
path: '',
title: 'Catalog',
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName/:selectedTabId/*',
title: 'Entity',
});
export const entityRouteDefault = createRouteRef({
icon: NoIcon,
path: '/catalog/:kind/:optionalNamespaceAndName',
path: ':kind/:optionalNamespaceAndName/*',
title: 'Entity',
});
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-circleci",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"postpack": "backstage-cli postpack"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -36,8 +36,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-explore",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,9 +32,9 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+9 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-github-actions",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -22,11 +22,11 @@
"mock-data": "./scripts/mock-data.sh"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/core-api": "^0.1.1-alpha.20",
"@backstage/plugin-catalog": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/core-api": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -35,12 +35,13 @@
"moment": "^2.27.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "6.0.0-beta.0",
"react-router-dom": "6.0.0-beta.0",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
@@ -74,7 +74,7 @@ const WidgetContent = ({
);
};
export const Widget = ({
export const LatestWorkflowRunCard = ({
entity,
branch = 'master',
}: {
@@ -109,50 +109,14 @@ export const Widget = ({
);
};
const RecentWorkflowRunsCardContent = ({
error,
loading,
branch,
}: {
error?: Error;
loading?: boolean;
branch: string;
}) => {
if (error) return <Typography>Couldn't fetch {branch} runs</Typography>;
if (loading) return <LinearProgress />;
return <WorkflowRunsTable />;
};
export const RecentWorkflowRunsCard = ({
export const LatestWorkflowsForBranchCard = ({
entity,
branch = 'master',
}: {
entity: Entity;
branch: string;
}) => {
const errorApi = useApi(errorApiRef);
const [owner, repo] = (
entity?.metadata.annotations?.['backstage.io/github-actions-id'] ?? '/'
).split('/');
const [{ loading, error }] = useWorkflowRuns({
owner,
repo,
branch,
});
useEffect(() => {
if (error) {
errorApi.post(error);
}
}, [error, errorApi]);
return (
<InfoCard title={`${branch} builds`}>
<RecentWorkflowRunsCardContent
error={error}
loading={loading}
branch={branch}
/>
</InfoCard>
);
};
}) => (
<InfoCard title={`Last ${branch} build`}>
<WorkflowRunsTable branch={branch} entity={entity} />
</InfoCard>
);
@@ -0,0 +1,16 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { LatestWorkflowRunCard, LatestWorkflowsForBranchCard } from './Cards';
@@ -0,0 +1,48 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Entity } from '@backstage/catalog-model';
import { Routes, Route } from 'react-router';
import { rootRouteRef, buildRouteRef } from '../plugin';
import { WorkflowRunDetails } from './WorkflowRunDetails';
import { WorkflowRunsTable } from './WorkflowRunsTable';
import { GITHUB_ACTIONS_ANNOTATION } from './useProjectName';
import { WarningPanel } from '@backstage/core';
const isPluginApplicableToEntity = (entity: Entity) =>
Boolean(entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION]) &&
entity.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] !== '';
export const Router = ({ entity }: { entity: Entity }) =>
// TODO(shmidt-i): move warning to a separate standardized component
!isPluginApplicableToEntity(entity) ? (
<WarningPanel title=" GitHubActions plugin:">
`entity.metadata.annotations['
{GITHUB_ACTIONS_ANNOTATION}']` key is missing on the entity.{' '}
</WarningPanel>
) : (
<Routes>
<Route
path={`/${rootRouteRef.path}`}
element={<WorkflowRunsTable entity={entity} />}
/>
<Route
path={`/${buildRouteRef.path}`}
element={<WorkflowRunDetails entity={entity} />}
/>
)
</Routes>
);
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import React from 'react';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useWorkflowRunsDetails } from './useWorkflowRunsDetails';
import { useWorkflowRunJobs } from './useWorkflowRunJobs';
import { useProjectName } from '../useProjectName';
@@ -35,13 +34,16 @@ import {
LinearProgress,
CircularProgress,
Theme,
Link,
Breadcrumbs,
Link as MaterialLink,
} from '@material-ui/core';
import { Jobs, Job, Step } from '../../api';
import moment from 'moment';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import ExpandMoreIcon from '@material-ui/icons/ExpandMore';
import ExternalLinkIcon from '@material-ui/icons/Launch';
import { Entity } from '@backstage/catalog-model';
import { Link } from '@backstage/core';
const useStyles = makeStyles<Theme>(theme => ({
root: {
@@ -140,18 +142,8 @@ const JobListItem = ({ job, className }: { job: Job; className: string }) => {
);
};
export const WorkflowRunDetails = () => {
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const projectName = useProjectName(entityCompoundName);
export const WorkflowRunDetails = ({ entity }: { entity: Entity }) => {
const projectName = useProjectName(entity);
const [owner, repo] = projectName.value ? projectName.value.split('/') : [];
const details = useWorkflowRunsDetails(repo, owner);
@@ -170,6 +162,10 @@ export const WorkflowRunDetails = () => {
}
return (
<div className={classes.root}>
<Breadcrumbs aria-label="breadcrumb">
<Link to="..">Workflow runs</Link>
<Typography>Workflow run details</Typography>
</Breadcrumbs>
<TableContainer component={Paper} className={classes.table}>
<Table>
<TableBody>
@@ -211,10 +207,10 @@ export const WorkflowRunDetails = () => {
</TableCell>
<TableCell>
{details.value?.html_url && (
<Link target="_blank" href={details.value.html_url}>
<MaterialLink target="_blank" href={details.value.html_url}>
Workflow runs on GitHub{' '}
<ExternalLinkIcon className={classes.externalLinkIcon} />
</Link>
</MaterialLink>
)}
</TableCell>
</TableRow>
@@ -1,65 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Typography, Grid, Breadcrumbs } from '@material-ui/core';
import React from 'react';
import {
Link,
Page,
Header,
HeaderLabel,
Content,
ContentHeader,
SupportButton,
pageTheme,
} from '@backstage/core';
import { WorkflowRunDetails } from '../WorkflowRunDetails';
/**
* A component for Jobs visualization. Jobs are a property of a Workflow Run.
*/
export const WorkflowRunDetailsPage = () => {
return (
<Page theme={pageTheme.tool}>
<Header
title="GitHub Actions"
subtitle="See recent workflow runs and their status"
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Workflow run details">
<SupportButton>
This plugin allows you to view and interact with your builds within
the GitHub Actions environment.
</SupportButton>
</ContentHeader>
<Breadcrumbs aria-label="breadcrumb">
<Link to="/github-actions">Workflow runs</Link>
<Typography>Workflow run details</Typography>
</Breadcrumbs>
<Grid container spacing={3} direction="column">
<Grid item>
<WorkflowRunDetails />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -1,56 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Header,
HeaderLabel,
pageTheme,
Page,
Content,
ContentHeader,
SupportButton,
} from '@backstage/core';
import { Grid } from '@material-ui/core';
import React from 'react';
import { WorkflowRunsTable } from '../WorkflowRunsTable';
export const WorkflowRunsPage = () => {
return (
<Page theme={pageTheme.tool}>
<Header
title="GitHub Actions"
subtitle="See recent workflow runs and their status"
>
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
<Content>
<ContentHeader title="Workflow runs">
<SupportButton>
This plugin allows you to view and interact with your builds within
the GitHub Actions environment.
</SupportButton>
</ContentHeader>
<Grid container spacing={3} direction="column">
<Grid item>
<WorkflowRunsTable />
</Grid>
</Grid>
</Content>
</Page>
);
};
@@ -23,8 +23,8 @@ import { useWorkflowRuns } from '../useWorkflowRuns';
import { WorkflowRunStatus } from '../WorkflowRunStatus';
import SyncIcon from '@material-ui/icons/Sync';
import { buildRouteRef } from '../../plugin';
import { useEntityCompoundName } from '@backstage/plugin-catalog';
import { useProjectName } from '../useProjectName';
import { Entity } from '@backstage/catalog-model';
export type WorkflowRun = {
id: string;
@@ -134,6 +134,7 @@ export const WorkflowRunsTableView: FC<Props> = ({
data={runs ?? []}
onChangePage={onChangePage}
onChangeRowsPerPage={onChangePageSize}
style={{ width: '100%' }}
title={
<Box display="flex" alignItems="center">
<GitHubIcon />
@@ -146,25 +147,21 @@ export const WorkflowRunsTableView: FC<Props> = ({
);
};
export const WorkflowRunsTable = () => {
let entityCompoundName = useEntityCompoundName();
if (!entityCompoundName.name) {
// TODO(shmidt-i): remove when is fully integrated
// into the entity view
entityCompoundName = {
kind: 'Component',
name: 'backstage',
namespace: 'default',
};
}
const { value: projectName, loading } = useProjectName(entityCompoundName);
export const WorkflowRunsTable = ({
entity,
branch,
}: {
entity: Entity;
branch?: string;
}) => {
const { value: projectName, loading } = useProjectName(entity);
const [owner, repo] = (projectName ?? '/').split('/');
const [tableProps, { retry, setPage, setPageSize }] = useWorkflowRuns({
owner,
repo,
branch,
});
return (
<WorkflowRunsTableView
{...tableProps}
@@ -15,15 +15,13 @@
*/
import { useAsync } from 'react-use';
import { catalogApiRef, EntityCompoundName } from '@backstage/plugin-catalog';
import { useApi } from '@backstage/core';
import { Entity } from '@backstage/catalog-model';
export const useProjectName = (name: EntityCompoundName) => {
const catalogApi = useApi(catalogApiRef);
export const GITHUB_ACTIONS_ANNOTATION = 'github.com/project-slug';
export const useProjectName = (entity: Entity) => {
const { value, loading, error } = useAsync(async () => {
const entity = await catalogApi.getEntityByName(name);
return entity?.metadata.annotations?.['github.com/project-slug'] ?? '';
return entity?.metadata.annotations?.[GITHUB_ACTIONS_ANNOTATION] ?? '';
});
return { value, loading, error };
};
+3 -1
View File
@@ -16,4 +16,6 @@
export { plugin } from './plugin';
export * from './api';
export { Widget } from './components/Widget';
export { Router } from './components/Router';
export * from './components/Cards';
export { GITHUB_ACTIONS_ANNOTATION } from './components/useProjectName';
+3 -13
View File
@@ -15,28 +15,18 @@
*/
import { createPlugin, createRouteRef } from '@backstage/core';
import { WorkflowRunDetailsPage } from './components/WorkflowRunDetailsPage';
import { WorkflowRunsPage } from './components/WorkflowRunsPage';
// TODO(freben): This is just a demo route for now
export const rootRouteRef = createRouteRef({
path: '/github-actions',
path: '',
title: 'GitHub Actions',
});
export const projectRouteRef = createRouteRef({
path: '/github-actions/:kind/:optionalNamespaceAndName/',
title: 'GitHub Actions for project',
});
export const buildRouteRef = createRouteRef({
path: '/github-actions/workflow-run/:id',
path: ':id',
title: 'GitHub Actions Workflow Run',
});
export const plugin = createPlugin({
id: 'github-actions',
register({ router }) {
router.addRoute(rootRouteRef, WorkflowRunsPage);
router.addRoute(projectRouteRef, WorkflowRunsPage);
router.addRoute(buildRouteRef, WorkflowRunDetailsPage);
},
});
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -32,8 +32,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+7 -7
View File
@@ -1,7 +1,7 @@
{
"name": "@backstage/plugin-graphiql",
"description": "Backstage plugin for browsing GraphQL APIs",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"private": false,
"publishConfig": {
"access": "public",
@@ -31,21 +31,21 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"graphiql": "^1.0.0-alpha.10",
"graphql": "15.1.0",
"graphql": "15.3.0",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-graphql-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,7 +20,7 @@
"mock-data": "./scripts/mock-data.sh"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"apollo-server": "^2.16.0",
"apollo-server-express": "^2.16.0",
@@ -32,7 +32,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/supertest": "^2.0.8",
"eslint-plugin-graphql": "^4.0.0",
"msw": "^0.20.5",
+6 -7
View File
@@ -14,17 +14,16 @@
* limitations under the License.
*/
import { errorHandler } from '@backstage/backend-common';
import { errorHandler, resolvePackagePath } from '@backstage/backend-common';
import express from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import fs from 'fs';
import path from 'path';
import { ApolloServer } from 'apollo-server-express';
const schemaPath = path.resolve(
require.resolve('@backstage/plugin-graphql-backend/package.json'),
'../schema.gql',
const schemaPath = resolvePackagePath(
'@backstage/plugin-graphql-backend',
'schema.gql',
);
export interface RouterOptions {
@@ -39,8 +38,8 @@ export async function createRouter(
const server = new ApolloServer({ typeDefs, logger: options.logger });
const router = Router();
const apolloMiddlware = server.getMiddleware({ path: '/' });
router.use(apolloMiddlware);
const apolloMiddleware = server.getMiddleware({ path: '/' });
router.use(apolloMiddleware);
router.get('/health', (_, response) => {
response.send({ status: 'ok' });
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-identity-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,7 +20,7 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -33,7 +33,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"jest-fetch-mock": "^3.0.3"
},
"files": [
@@ -27,13 +27,15 @@ export interface RouterOptions {
const makeRouter = (adapter: IdentityApi): express.Router => {
const router = Router();
router.use(express.json());
router.get('/users/:user/groups', async (req, res) => {
const user = req.params.user;
const type = req.query.type?.toString() ?? '';
const response = await adapter.getUserGroups({ user, type });
res.send(response);
});
return router;
};
+6 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-jenkins",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,9 +21,9 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -35,8 +35,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+13 -5
View File
@@ -36,16 +36,24 @@ your [`apis.ts`](https://github.com/spotify/backstage/blob/master/packages/app/s
```js
import { ApiHolder, ApiRegistry } from '@backstage/core';
import { Config } from '@backstage/config';
import {
lighthouseApiRef,
LighthouseRestApi,
} from '@backstage/plugin-lighthouse';
const builder = ApiRegistry.builder();
export const apis = (config: ConfigApi) => {
const builder = ApiRegistry.builder();
export const lighthouseApi =
new LighthouseRestApi(/* your service url here! */);
builder.add(lighthouseApiRef, lighthouseApi);
builder.add(lighthouseApiRef, LighthouseRestApi.fromConfig(config));
export default builder.build() as ApiHolder;
return builder.build() as ApiHolder;
}
```
Then configure the lighthouse service url in your [`app-config.yaml`](https://github.com/spotify/backstage/blob/master/app-config.yaml).
```yaml
lighthouse:
baseUrl: http://your-service-url
```
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-lighthouse",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,9 @@
"start": "backstage-cli plugin:serve"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -33,9 +34,9 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/test-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@backstage/test-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+5
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '@backstage/core';
import { Config } from '@backstage/config';
export type LighthouseCategoryId =
| 'pwa'
@@ -111,6 +112,10 @@ export const lighthouseApiRef = createApiRef<LighthouseApi>({
});
export class LighthouseRestApi implements LighthouseApi {
static fromConfig(config: Config) {
return new LighthouseRestApi(config.getString('lighthouse.baseUrl'));
}
constructor(public url: string) {}
private async fetch<T = any>(input: string, init?: RequestInit): Promise<T> {
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-newrelic",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,8 +21,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
@@ -31,8 +31,8 @@
"react-use": "^15.3.3"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
+12 -16
View File
@@ -1,6 +1,7 @@
# Proxy backend plugin
This is the backend plugin that enables proxy definitions to be declared in and read from app-config.yaml.
This is the backend plugin that enables proxy definitions to be declared in,
and read from, `app-config.yaml`.
Relies on the `http-proxy-middleware` package.
@@ -10,27 +11,22 @@ This backend plugin can be started in a standalone mode from directly in this pa
with `yarn start`. However, it will have limited functionality and that process is
most convenient when developing the plugin itself.
To run it within the backend do:
1. Register the router in `packages/backend/src/index.ts`:
```ts
const proxyEnv = useHotMemoize(module, () => createEnv('proxy'));
const service = createServiceBuilder(module)
.loadConfig(configReader)
/** several different routers */
.addRouter('/', await proxy(proxyEnv));
```
2. Start the backend
The proxy is already installed in the Backstage backend per default, so you can also
start up the full example backend to experiment with the proxy.
```bash
yarn workspace example-backend start
```
This will launch the full example backend.
## Configuration
See [the proxy docs](https://backstage.io/docs/plugins/proxying).
## Links
- [Call Existing API](https://backstage.io/docs/plugins/call-existing-api) helps the
decision process of what method of communication to use from a frontend plugin to
your API
- [The proxy plugin documentation](https://backstage.io/docs/plugins/proxying) describes
configuration options and more
- [http-proxy-middleware](https://www.npmjs.com/package/http-proxy-middleware)
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-proxy-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -19,8 +19,8 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
@@ -34,7 +34,7 @@
"yup": "^0.29.1"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/node-fetch": "^2.5.7",
"@types/supertest": "^2.0.8",
"@types/uuid": "^8.0.0",
@@ -26,6 +26,7 @@ describe('createRouter', () => {
const router = await createRouter({
config,
logger,
pathPrefix: '/proxy',
});
expect(router).toBeDefined();
});
+48 -3
View File
@@ -17,21 +17,66 @@
import { Config } from '@backstage/config';
import express from 'express';
import Router from 'express-promise-router';
import createProxyMiddleware from 'http-proxy-middleware';
import createProxyMiddleware, {
Config as ProxyConfig,
Proxy,
} from 'http-proxy-middleware';
import { Logger } from 'winston';
export interface RouterOptions {
logger: Logger;
config: Config;
// The URL path prefix that the router itself is mounted as, commonly "/proxy"
pathPrefix: string;
}
// Creates a proxy middleware, possibly with defaults added on top of the
// given config.
function buildMiddleware(
pathPrefix: string,
logger: Logger,
route: string,
config: string | ProxyConfig,
): Proxy {
const fullConfig =
typeof config === 'string' ? { target: config } : { ...config };
// Default is to do a path rewrite that strips out the proxy's path prefix
// and the rest of the route.
if (fullConfig.pathRewrite === undefined) {
const routeWithSlash = route.endsWith('/') ? route : `${route}/`;
fullConfig.pathRewrite = {
[`^${pathPrefix}${routeWithSlash}`]: '/',
};
}
// Default is to update the Host header to the target
if (fullConfig.changeOrigin === undefined) {
fullConfig.changeOrigin = true;
}
// Attach the logger to the proxy config
fullConfig.logProvider = () => logger;
return createProxyMiddleware(fullConfig);
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const router = Router();
const proxyConfig = options.config.get('proxy') ?? {};
const proxyConfig = options.config.getOptional('proxy') ?? {};
Object.entries(proxyConfig).forEach(([route, proxyRouteConfig]) => {
router.use(route, createProxyMiddleware(proxyRouteConfig));
router.use(
route,
buildMiddleware(
options.pathPrefix,
options.logger,
route,
proxyRouteConfig,
),
);
});
return router;
@@ -40,6 +40,7 @@ export async function startStandaloneServer(
const router = await createRouter({
config,
logger,
pathPrefix: '/proxy',
});
const service = createServiceBuilder(module)
.enableCors({ origin: 'http://localhost:3000' })
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-register-component",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -21,23 +21,23 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/catalog-model": "^0.1.1-alpha.20",
"@backstage/core": "^0.1.1-alpha.20",
"@backstage/plugin-catalog": "^0.1.1-alpha.20",
"@backstage/theme": "^0.1.1-alpha.20",
"@backstage/catalog-model": "^0.1.1-alpha.21",
"@backstage/core": "^0.1.1-alpha.21",
"@backstage/plugin-catalog": "^0.1.1-alpha.21",
"@backstage/theme": "^0.1.1-alpha.21",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hook-form": "^5.7.2",
"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"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/dev-utils": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@backstage/dev-utils": "^0.1.1-alpha.21",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^10.4.1",
"@testing-library/user-event": "^12.0.7",
@@ -14,10 +14,10 @@
* limitations under the License.
*/
import { cleanup, fireEvent, render } from '@testing-library/react';
import React from 'react';
import { render, fireEvent, cleanup } from '@testing-library/react';
import RegisterComponentForm, { Props } from './RegisterComponentForm';
import { act } from 'react-dom/test-utils';
import RegisterComponentForm, { Props } from './RegisterComponentForm';
const setup = (props?: Partial<Props>) => {
return {
@@ -37,7 +37,7 @@ describe('RegisterComponentForm', () => {
const { rendered } = setup();
expect(
await rendered.findByText(
'Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component. For private repo provide authentication information via config.',
'Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component.',
),
).toBeInTheDocument();
@@ -14,17 +14,17 @@
* limitations under the License.
*/
import React, { FC } from 'react';
import { BackstageTheme } from '@backstage/theme';
import {
Button,
FormControl,
FormHelperText,
TextField,
LinearProgress,
TextField,
} from '@material-ui/core';
import { useForm } from 'react-hook-form';
import { makeStyles } from '@material-ui/core/styles';
import { BackstageTheme } from '@backstage/theme';
import React, { FC } from 'react';
import { useForm } from 'react-hook-form';
import { ComponentIdValidators } from '../../util/validate';
const useStyles = makeStyles<BackstageTheme>(theme => ({
@@ -49,7 +49,7 @@ const RegisterComponentForm: FC<Props> = ({ onSubmit, submitting }) => {
});
const classes = useStyles();
const hasErrors = !!errors.componentLocation;
const dirty = formState?.dirty;
const dirty = formState?.isDirty;
return submitting ? (
<LinearProgress data-testid="loading-progress" />
@@ -71,7 +71,7 @@ const RegisterComponentForm: FC<Props> = ({ onSubmit, submitting }) => {
name="componentLocation"
required
margin="normal"
helperText="Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component. For private repo provide authentication information via config."
helperText="Enter the full path to the component.yaml file in GitHub, GitLab, Bitbucket or Azure to start tracking your component."
inputRef={register({
required: true,
validate: ComponentIdValidators,
@@ -37,6 +37,7 @@ describe('ComponentIdValidators', () => {
[true, 'http://example.com/blob/master/service.yaml'],
[true, 'https://example.yaml'],
[true, 'https://example.com?path=abc.yaml&c=1'],
[errorMessage, 'https://example.com?path=abc_yaml&c=1'],
[errorMessage, '.yml'],
[errorMessage, 'http://example.com/blob/master/service'],
[errorMessage, undefined],
@@ -19,6 +19,6 @@ export const ComponentIdValidators = {
(typeof value === 'string' && value.match(/^https:\/\//) !== null) ||
'Must start with https://.',
yamlValidator: (value: any) =>
(typeof value === 'string' && value.match(/.yaml/) !== null) ||
(typeof value === 'string' && value.match(/\.yaml/) !== null) ||
"Must contain '.yaml'.",
};
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-rollbar-backend",
"version": "0.1.1-alpha.20",
"version": "0.1.1-alpha.21",
"main": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
@@ -20,10 +20,10 @@
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.20",
"@backstage/config": "^0.1.1-alpha.20",
"@backstage/backend-common": "^0.1.1-alpha.21",
"@backstage/config": "^0.1.1-alpha.21",
"@types/express": "^4.17.6",
"axios": "^0.19.2",
"axios": "^0.20.0",
"camelcase-keys": "^6.2.2",
"compression": "^1.7.4",
"cors": "^2.8.5",
@@ -37,7 +37,7 @@
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.20",
"@backstage/cli": "^0.1.1-alpha.21",
"@types/supertest": "^2.0.8",
"jest-fetch-mock": "^3.0.3",
"supertest": "^4.0.2"

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