Merge branch 'master' of github.com:spotify/backstage into feat/backend-plugin
* 'master' of github.com:spotify/backstage: (21 commits) core/package.json: pin core-api to same version as core [microsite] tweaking CNCF logo styling (#2529) feat(catalog): show only components fix(gcp-projects): some postponed cleanup of the plugin code (#2523) feat: move add example components to a more visible space and change behavior use version from lerna.json or fallback to 0.1.0 remove scope from registry revert templatingTask revert templatingTask test update templatingTask test fix plugin template verification fix plugin:diff remove console.log fix plugin:diff un-escape var keep addPluginDependencyToApp argument consistent detect monorepo fix registryURL rename packageName improvement ...
This commit is contained in:
@@ -471,8 +471,8 @@ class Index extends React.Component {
|
||||
Cloud Native Computing Foundation
|
||||
</a>{' '}
|
||||
sandbox project
|
||||
<div className="cncf-logo" />
|
||||
</Block.SmallTitle>
|
||||
<div className="cncf-logo" />
|
||||
</Block.Container>
|
||||
</Block>
|
||||
</main>
|
||||
|
||||
@@ -1072,6 +1072,7 @@ code {
|
||||
}
|
||||
|
||||
.cncf-block {
|
||||
padding-top: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1080,4 +1081,5 @@ code {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
margin-bottom: 40px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
"docgen": "lerna run docgen",
|
||||
"docker-build:app": "yarn workspace example-app build && docker build . -t spotify/backstage",
|
||||
"docker-build": "yarn tsc && yarn workspace example-backend build-image",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
|
||||
"remove-plugin": "backstage-cli remove-plugin",
|
||||
"release": "if [ \"$(git symbolic-ref --short HEAD)\" = master ]; then echo \"don't try to release master\"; exit 1; else lerna version --no-push --force-publish; fi",
|
||||
"prettier:check": "prettier --check .",
|
||||
|
||||
@@ -21,6 +21,7 @@ import inquirer, { Answers, Question } from 'inquirer';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import os from 'os';
|
||||
import { Command } from 'commander';
|
||||
import {
|
||||
parseOwnerIds,
|
||||
addCodeownersEntry,
|
||||
@@ -32,12 +33,12 @@ import { version as backstageVersion } from '../../lib/version';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
async function checkExists(rootDir: string, id: string) {
|
||||
await Task.forItem('checking', id, async () => {
|
||||
const destination = resolvePath(rootDir, 'plugins', id);
|
||||
|
||||
async function checkExists(destination: string) {
|
||||
await Task.forItem('checking', destination, async () => {
|
||||
if (await fs.pathExists(destination)) {
|
||||
const existing = chalk.cyan(destination.replace(`${rootDir}/`, ''));
|
||||
const existing = chalk.cyan(
|
||||
destination.replace(`${paths.targetRoot}/`, ''),
|
||||
);
|
||||
throw new Error(
|
||||
`A plugin with the same name already exists: ${existing}\nPlease try again with a different plugin ID`,
|
||||
);
|
||||
@@ -86,10 +87,9 @@ export const addExportStatement = async (
|
||||
|
||||
export async function addPluginDependencyToApp(
|
||||
rootDir: string,
|
||||
pluginName: string,
|
||||
pluginPackage: string,
|
||||
versionStr: string,
|
||||
) {
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
const packageFilePath = 'packages/app/package.json';
|
||||
const packageFile = resolvePath(rootDir, packageFilePath);
|
||||
|
||||
@@ -116,8 +116,11 @@ export async function addPluginDependencyToApp(
|
||||
});
|
||||
}
|
||||
|
||||
export async function addPluginToApp(rootDir: string, pluginName: string) {
|
||||
const pluginPackage = `@backstage/plugin-${pluginName}`;
|
||||
export async function addPluginToApp(
|
||||
rootDir: string,
|
||||
pluginName: string,
|
||||
pluginPackage: string,
|
||||
) {
|
||||
const pluginNameCapitalized = pluginName
|
||||
.split('-')
|
||||
.map(name => capitalize(name))
|
||||
@@ -175,7 +178,7 @@ export async function movePlugin(
|
||||
});
|
||||
}
|
||||
|
||||
export default async ({ backend }: { backend: boolean }) => {
|
||||
export default async (cmd: Command) => {
|
||||
const codeownersPath = await getCodeownersFilePath(paths.targetRoot);
|
||||
|
||||
const questions: Question[] = [
|
||||
@@ -221,22 +224,33 @@ export default async ({ backend }: { backend: boolean }) => {
|
||||
}
|
||||
|
||||
const answers: Answers = await inquirer.prompt(questions);
|
||||
|
||||
const name = cmd.scope
|
||||
? `@${cmd.scope.replace(/^@/, '')}/plugin-${answers.id}`
|
||||
: `plugin-${answers.id}`;
|
||||
const npmRegistry = cmd.npmRegistry && cmd.scope ? cmd.npmRegistry : '';
|
||||
const privatePackage = cmd.private === false ? false : true;
|
||||
const isMonoRepo = await fs.pathExists(paths.resolveTargetRoot('lerna.json'));
|
||||
const appPackage = paths.resolveTargetRoot('packages/app');
|
||||
const templateDir = paths.resolveOwn(
|
||||
backend ? 'templates/default-backend-plugin' : 'templates/default-plugin',
|
||||
cmd.backend
|
||||
? 'templates/default-backend-plugin'
|
||||
: 'templates/default-plugin',
|
||||
);
|
||||
const tempDir = resolvePath(os.tmpdir(), answers.id);
|
||||
const pluginDir = paths.resolveTargetRoot('plugins', answers.id);
|
||||
const pluginDir = isMonoRepo
|
||||
? paths.resolveTargetRoot('plugins', answers.id)
|
||||
: paths.resolveTargetRoot(answers.id);
|
||||
const ownerIds = parseOwnerIds(answers.owner);
|
||||
const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json'));
|
||||
const { version } = isMonoRepo
|
||||
? await fs.readJson(paths.resolveTargetRoot('lerna.json'))
|
||||
: { version: '0.1.0' };
|
||||
|
||||
Task.log();
|
||||
Task.log('Creating the plugin...');
|
||||
|
||||
try {
|
||||
Task.section('Checking if the plugin ID is available');
|
||||
await checkExists(paths.targetRoot, answers.id);
|
||||
await checkExists(pluginDir);
|
||||
|
||||
Task.section('Creating a temporary plugin directory');
|
||||
await createTemporaryPluginFolder(tempDir);
|
||||
@@ -247,6 +261,9 @@ export default async ({ backend }: { backend: boolean }) => {
|
||||
...answers,
|
||||
version,
|
||||
backstageVersion,
|
||||
name,
|
||||
privatePackage,
|
||||
npmRegistry,
|
||||
});
|
||||
|
||||
Task.section('Moving to final location');
|
||||
@@ -257,10 +274,10 @@ export default async ({ backend }: { backend: boolean }) => {
|
||||
|
||||
if ((await fs.pathExists(appPackage)) && !backend) {
|
||||
Task.section('Adding plugin as dependency in app');
|
||||
await addPluginDependencyToApp(paths.targetRoot, answers.id, version);
|
||||
await addPluginDependencyToApp(paths.targetRoot, name, version);
|
||||
|
||||
Task.section('Import plugin in app');
|
||||
await addPluginToApp(paths.targetRoot, answers.id);
|
||||
await addPluginToApp(paths.targetRoot, answers.id, name);
|
||||
}
|
||||
|
||||
if (ownerIds && ownerIds.length) {
|
||||
@@ -272,11 +289,7 @@ export default async ({ backend }: { backend: boolean }) => {
|
||||
}
|
||||
|
||||
Task.log();
|
||||
Task.log(
|
||||
`🥇 Successfully created ${chalk.cyan(
|
||||
`@backstage/plugin-${answers.id}`,
|
||||
)}`,
|
||||
);
|
||||
Task.log(`🥇 Successfully created ${chalk.cyan(`${name}`)}`);
|
||||
Task.log();
|
||||
Task.exit();
|
||||
} catch (error) {
|
||||
|
||||
@@ -66,6 +66,9 @@ export function registerCommands(program: CommanderStatic) {
|
||||
'Create plugin with the backend dependencies as default',
|
||||
)
|
||||
.description('Creates a new plugin in the current repository')
|
||||
.option('--scope <scope>', 'NPM scope')
|
||||
.option('--npm-registry <URL>', 'NPM registry URL')
|
||||
.option('--no-private', 'Public NPM Package')
|
||||
.action(
|
||||
lazy(() => import('./create-plugin/createPlugin').then(m => m.default)),
|
||||
);
|
||||
|
||||
@@ -30,6 +30,9 @@ import { version as backstageVersion } from '../../lib/version';
|
||||
export type PluginData = {
|
||||
id: string;
|
||||
name: string;
|
||||
privatePackage: string;
|
||||
version: string;
|
||||
npmRegistry: string;
|
||||
};
|
||||
|
||||
const fileHandlers = [
|
||||
@@ -62,11 +65,8 @@ export default async (cmd: Command) => {
|
||||
promptFunc = yesPromptFunc;
|
||||
}
|
||||
|
||||
const { version } = await fs.readJson(paths.resolveTargetRoot('lerna.json'));
|
||||
|
||||
const data = await readPluginData();
|
||||
const templateFiles = await diffTemplateFiles('default-plugin', {
|
||||
version,
|
||||
backstageVersion,
|
||||
...data,
|
||||
});
|
||||
@@ -77,9 +77,19 @@ export default async (cmd: Command) => {
|
||||
// Reads templating data from the existing plugin
|
||||
async function readPluginData(): Promise<PluginData> {
|
||||
let name: string;
|
||||
let privatePackage: string;
|
||||
let version: string;
|
||||
let npmRegistry: string;
|
||||
try {
|
||||
const pkg = require(paths.resolveTarget('package.json'));
|
||||
name = pkg.name;
|
||||
privatePackage = pkg.private;
|
||||
version = pkg.version;
|
||||
const scope = name.split('/')[0];
|
||||
if (`${scope}:registry` in pkg.publishConfig) {
|
||||
const registryURL = pkg.publishConfig[`${scope}:registry`];
|
||||
npmRegistry = `"${scope}:registry" : "${registryURL}"`;
|
||||
} else npmRegistry = '';
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read target package, ${error}`);
|
||||
}
|
||||
@@ -96,5 +106,5 @@ async function readPluginData(): Promise<PluginData> {
|
||||
|
||||
const id = pluginIdMatch[1];
|
||||
|
||||
return { id, name };
|
||||
return { id, name, privatePackage, version, npmRegistry };
|
||||
}
|
||||
|
||||
@@ -1,41 +1,44 @@
|
||||
{
|
||||
"name": "@backstage/plugin-{{id}}-backend",
|
||||
"name": "{{name}}",
|
||||
"version": "{{version}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
{{#if privatePackage}} "private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"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": "^{{version}}",
|
||||
"@backstage/config": "^{{version}}",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"winston": "^3.2.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version}}",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2",
|
||||
"msw": "^0.19.5"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"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": "^{{version}}",
|
||||
"@backstage/config": "^{{version}}",
|
||||
"@types/express": "^4.17.6",
|
||||
"express": "^4.17.1",
|
||||
"express-promise-router": "^3.0.3",
|
||||
"winston": "^3.2.1",
|
||||
"node-fetch": "^2.6.0",
|
||||
"yn": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^{{version}}",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"supertest": "^4.0.2",
|
||||
"msw": "^0.19.5"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
{
|
||||
"name": "@backstage/plugin-{{id}}",
|
||||
"name": "{{name}}",
|
||||
"version": "{{version}}",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
{{#if privatePackage}} "private": {{privatePackage}},
|
||||
{{/if}}
|
||||
"publishConfig": {
|
||||
{{#if npmRegistry}} "registry": "{{npmRegistry}}",
|
||||
{{/if}}
|
||||
"access": "public",
|
||||
"main": "dist/index.esm.js",
|
||||
"types": "dist/index.d.ts"
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@backstage/config": "^0.1.1-alpha.22",
|
||||
"@backstage/core-api": "^0.1.1-alpha.22",
|
||||
"@backstage/core-api": "0.1.1-alpha.22",
|
||||
"@backstage/theme": "^0.1.1-alpha.22",
|
||||
"@material-ui/core": "^4.11.0",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"test:all": "lerna run test -- --coverage",
|
||||
"lint": "lerna run lint --since origin/master --",
|
||||
"lint:all": "lerna run lint --",
|
||||
"create-plugin": "backstage-cli create-plugin",
|
||||
"create-plugin": "backstage-cli create-plugin --scope backstage --no-private",
|
||||
"remove-plugin": "backstage-cli remove-plugin"
|
||||
},
|
||||
"workspaces": {
|
||||
|
||||
@@ -81,7 +81,11 @@ async function buildDistWorkspace(workspaceName: string, rootDir: string) {
|
||||
const path = paths.resolveOwnRoot(pkgJsonPath);
|
||||
const pkgTemplate = await fs.readFile(path, 'utf8');
|
||||
const { dependencies = {}, devDependencies = {} } = JSON.parse(
|
||||
handlebars.compile(pkgTemplate)({ version: '0.0.0' }),
|
||||
handlebars.compile(pkgTemplate)({
|
||||
version: '0.0.0',
|
||||
privatePackage: true,
|
||||
scopeName: '@backstage',
|
||||
}),
|
||||
);
|
||||
|
||||
Array<string>()
|
||||
|
||||
@@ -46,6 +46,9 @@ const useStyles = makeStyles(theme => ({
|
||||
gridTemplateColumns: '250px 1fr',
|
||||
gridColumnGap: theme.spacing(2),
|
||||
},
|
||||
buttonSpacing: {
|
||||
marginLeft: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const CatalogPageContents = () => {
|
||||
@@ -56,6 +59,7 @@ const CatalogPageContents = () => {
|
||||
reload,
|
||||
matchingEntities,
|
||||
availableTags,
|
||||
isCatalogEmpty,
|
||||
} = useFilteredEntities();
|
||||
const configApi = useApi(configApiRef);
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
@@ -141,6 +145,9 @@ const CatalogPageContents = () => {
|
||||
[isStarredEntity, userId, orgName],
|
||||
);
|
||||
|
||||
const showAddExampleEntities =
|
||||
configApi.has('catalog.exampleEntityLocations') && isCatalogEmpty;
|
||||
|
||||
return (
|
||||
<CatalogLayout>
|
||||
<CatalogTabs
|
||||
@@ -158,6 +165,16 @@ const CatalogPageContents = () => {
|
||||
>
|
||||
Create Component
|
||||
</Button>
|
||||
{showAddExampleEntities && (
|
||||
<Button
|
||||
className={styles.buttonSpacing}
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={addMockData}
|
||||
>
|
||||
Add example components
|
||||
</Button>
|
||||
)}
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<div className={styles.contentWrapper}>
|
||||
@@ -174,7 +191,6 @@ const CatalogPageContents = () => {
|
||||
entities={matchingEntities}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onAddMockData={addMockData}
|
||||
/>
|
||||
</div>
|
||||
</Content>
|
||||
|
||||
@@ -46,7 +46,6 @@ describe('CatalogTable component', () => {
|
||||
entities={[]}
|
||||
loading={false}
|
||||
error={{ code: 'error' }}
|
||||
onAddMockData={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
@@ -63,7 +62,6 @@ describe('CatalogTable component', () => {
|
||||
titlePreamble="Owned"
|
||||
entities={entities}
|
||||
loading={false}
|
||||
onAddMockData={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -16,11 +16,10 @@
|
||||
import { Entity, LocationSpec } from '@backstage/catalog-model';
|
||||
import { Table, TableColumn, TableProps } from '@backstage/core';
|
||||
import { Chip, Link } from '@material-ui/core';
|
||||
import Add from '@material-ui/icons/Add';
|
||||
import Edit from '@material-ui/icons/Edit';
|
||||
import GitHub from '@material-ui/icons/GitHub';
|
||||
import { Alert } from '@material-ui/lab';
|
||||
import React, { Dispatch } from 'react';
|
||||
import React from 'react';
|
||||
import { generatePath, Link as RouterLink } from 'react-router-dom';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
@@ -87,7 +86,6 @@ type CatalogTableProps = {
|
||||
titlePreamble: string;
|
||||
loading: boolean;
|
||||
error?: any;
|
||||
onAddMockData: Dispatch<void>;
|
||||
};
|
||||
|
||||
export const CatalogTable = ({
|
||||
@@ -95,7 +93,6 @@ export const CatalogTable = ({
|
||||
loading,
|
||||
error,
|
||||
titlePreamble,
|
||||
onAddMockData,
|
||||
}: CatalogTableProps) => {
|
||||
const { isStarredEntity, toggleStarredEntity } = useStarredEntities();
|
||||
|
||||
@@ -151,13 +148,6 @@ export const CatalogTable = ({
|
||||
onClick: () => toggleStarredEntity(rowData),
|
||||
};
|
||||
},
|
||||
{
|
||||
icon: () => <Add />,
|
||||
tooltip: 'Add example components',
|
||||
isFreeAction: true,
|
||||
onClick: onAddMockData,
|
||||
hidden: !(entities && entities.length === 0),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -47,7 +47,7 @@ export const EntityFilterGroupsProvider = ({
|
||||
function useProvideEntityFilters(): FilterGroupsContext {
|
||||
const catalogApi = useApi(catalogApiRef);
|
||||
const [{ value: entities, error }, doReload] = useAsyncFn(() =>
|
||||
catalogApi.getEntities(),
|
||||
catalogApi.getEntities({ kind: 'Component' }),
|
||||
);
|
||||
|
||||
const filterGroups = useRef<{
|
||||
@@ -62,6 +62,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
}>({});
|
||||
const [matchingEntities, setMatchingEntities] = useState<Entity[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [isCatalogEmpty, setCatalogEmpty] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
doReload();
|
||||
@@ -86,6 +87,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
),
|
||||
);
|
||||
setAvailableTags(collectTags(entities));
|
||||
setCatalogEmpty(entities !== undefined && entities.length === 0);
|
||||
}, [entities, error]);
|
||||
|
||||
const register = useCallback(
|
||||
@@ -143,6 +145,7 @@ function useProvideEntityFilters(): FilterGroupsContext {
|
||||
filterGroupStates,
|
||||
matchingEntities,
|
||||
availableTags,
|
||||
isCatalogEmpty,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export type FilterGroupsContext = {
|
||||
filterGroupStates: { [filterGroupId: string]: FilterGroupStates };
|
||||
matchingEntities: Entity[];
|
||||
availableTags: string[];
|
||||
isCatalogEmpty: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useFilteredEntities() {
|
||||
error: context.error,
|
||||
matchingEntities: context.matchingEntities,
|
||||
availableTags: context.availableTags,
|
||||
isCatalogEmpty: context.isCatalogEmpty,
|
||||
reload: context.reload,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,124 +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 { GCPApi } from './GCPApi';
|
||||
import { Project, Operation, Status } from './types';
|
||||
|
||||
const BaseURL =
|
||||
'https://content-cloudresourcemanager.googleapis.com/v1/projects';
|
||||
|
||||
export class GCPClient implements GCPApi {
|
||||
async listProjects({ token }: { token: string }): Promise<Project[]> {
|
||||
const response = await fetch(BaseURL, {
|
||||
headers: new Headers({
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return [
|
||||
{
|
||||
name: 'Error',
|
||||
projectNumber: 'Response status is not OK',
|
||||
projectId: 'Error',
|
||||
lifecycleState: 'error',
|
||||
createTime: 'Error',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async getProject(
|
||||
projectId: string,
|
||||
token: Promise<string>,
|
||||
): Promise<Project> {
|
||||
const url = `${BaseURL}/${projectId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: new Headers({
|
||||
Authorization: `Bearer ${await token}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const dataBlank: Project = {
|
||||
name: 'Error',
|
||||
projectNumber: `Response status is ${response.status}`,
|
||||
projectId: 'Error',
|
||||
lifecycleState: 'error',
|
||||
createTime: 'Error',
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
return dataBlank;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const newData: Project = data;
|
||||
|
||||
return newData;
|
||||
}
|
||||
|
||||
async createProject(
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
token: string,
|
||||
): Promise<Operation> {
|
||||
const status: Status = {
|
||||
code: 0,
|
||||
message: '',
|
||||
details: [],
|
||||
};
|
||||
|
||||
const op: Operation = {
|
||||
name: '',
|
||||
metadata: '',
|
||||
done: true,
|
||||
error: status,
|
||||
response: '',
|
||||
};
|
||||
|
||||
const newProject: Project = {
|
||||
name: projectName,
|
||||
projectId: projectId,
|
||||
};
|
||||
|
||||
const body = JSON.stringify(newProject);
|
||||
|
||||
const response = await fetch(BaseURL, {
|
||||
headers: new Headers({
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${token}`,
|
||||
}),
|
||||
body: body,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
status.code = response.status;
|
||||
return op;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,16 @@
|
||||
import { createApiRef } from '@backstage/core';
|
||||
import { Project, Operation } from './types';
|
||||
|
||||
export const GCPApiRef = createApiRef<GCPApi>({
|
||||
export const gcpApiRef = createApiRef<GcpApi>({
|
||||
id: 'plugin.gcpprojects.service',
|
||||
description: 'Used by the GCP Projects plugin to make requests',
|
||||
});
|
||||
|
||||
export type GCPApi = {
|
||||
listProjects: ({ token }: { token: string }) => Promise<Project[]>;
|
||||
getProject: (projectId: string, token: Promise<string>) => Promise<Project>;
|
||||
createProject: (
|
||||
projectName: string,
|
||||
projectId: string,
|
||||
owner: string,
|
||||
token: string,
|
||||
) => Promise<Operation>;
|
||||
export type GcpApi = {
|
||||
listProjects(): Promise<Project[]>;
|
||||
getProject(projectId: string): Promise<Project>;
|
||||
createProject(options: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}): Promise<Operation>;
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* 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 { OAuthApi } from '@backstage/core';
|
||||
import { GcpApi } from './GcpApi';
|
||||
import { Operation, Project } from './types';
|
||||
|
||||
const BASE_URL =
|
||||
'https://content-cloudresourcemanager.googleapis.com/v1/projects';
|
||||
|
||||
export class GcpClient implements GcpApi {
|
||||
constructor(private readonly googleAuthApi: OAuthApi) {}
|
||||
|
||||
async listProjects(): Promise<Project[]> {
|
||||
const response = await fetch(BASE_URL, {
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`List request failed to ${BASE_URL} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { projects } = await response.json();
|
||||
return projects;
|
||||
}
|
||||
|
||||
async getProject(projectId: string): Promise<Project> {
|
||||
const url = `${BASE_URL}/${projectId}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Get request failed to ${url} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async createProject(options: {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
}): Promise<Operation> {
|
||||
const newProject: Project = {
|
||||
name: options.projectName,
|
||||
projectId: options.projectId,
|
||||
};
|
||||
|
||||
const response = await fetch(BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: '*/*',
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
},
|
||||
body: JSON.stringify(newProject),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Create request failed to ${BASE_URL} with ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
// NOTE(freben): There's a .read-only variant of this scope that we could
|
||||
// use for readonly operations, but that means we would ask the user for a
|
||||
// second auth during creation and I decided to keep the wider scope for
|
||||
// all ops for now
|
||||
return this.googleAuthApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export * from './GCPApi';
|
||||
export * from './GCPClient';
|
||||
export * from './GcpApi';
|
||||
export * from './GcpClient';
|
||||
export * from './types';
|
||||
|
||||
@@ -14,24 +14,23 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useState } from 'react';
|
||||
import { Grid, Button, TextField } from '@material-ui/core';
|
||||
|
||||
import {
|
||||
InfoCard,
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
InfoCard,
|
||||
Page,
|
||||
pageTheme,
|
||||
SimpleStepper,
|
||||
SimpleStepperStep,
|
||||
StructuredMetadataTable,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
} from '@backstage/core';
|
||||
import { Button, Grid, TextField } from '@material-ui/core';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
export const Project: FC<{}> = () => {
|
||||
export const Project = () => {
|
||||
const [projectName, setProjectName] = useState('');
|
||||
const [projectId, setProjectId] = useState('');
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
|
||||
@@ -14,6 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
@@ -27,20 +38,9 @@ import {
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
Content,
|
||||
ContentHeader,
|
||||
} from '@backstage/core';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { GCPApiRef } from '../../api';
|
||||
import { gcpApiRef } from '../../api';
|
||||
|
||||
const useStyles = makeStyles<Theme>(theme => ({
|
||||
root: {
|
||||
@@ -56,34 +56,27 @@ const useStyles = makeStyles<Theme>(theme => ({
|
||||
}));
|
||||
|
||||
const DetailsPage = () => {
|
||||
const api = useApi(GCPApiRef);
|
||||
const googleApi = useApi(googleAuthApiRef);
|
||||
const token = googleApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform.read-only',
|
||||
);
|
||||
|
||||
const api = useApi(gcpApiRef);
|
||||
const classes = useStyles();
|
||||
const status = useAsync(
|
||||
() =>
|
||||
|
||||
const { loading, error, value: details } = useAsync(
|
||||
async () =>
|
||||
api.getProject(
|
||||
decodeURIComponent(location.search.split('projectId=')[1]),
|
||||
token,
|
||||
),
|
||||
[location.search],
|
||||
);
|
||||
|
||||
if (status.loading) {
|
||||
if (loading) {
|
||||
return <LinearProgress />;
|
||||
} else if (status.error) {
|
||||
} else if (error) {
|
||||
return (
|
||||
<Typography variant="h6" color="error">
|
||||
Failed to load build, {status.error.message}
|
||||
</Typography>
|
||||
<WarningPanel title="Failed to load project">
|
||||
{error.toString()}
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
const details = status.value;
|
||||
|
||||
return (
|
||||
<Table component={Paper} className={classes.table}>
|
||||
<Table>
|
||||
|
||||
@@ -17,18 +17,19 @@
|
||||
// NEEDS WORK
|
||||
|
||||
import {
|
||||
Link,
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
HeaderLabel,
|
||||
Page,
|
||||
Header,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
Content,
|
||||
ContentHeader,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
Link,
|
||||
Page,
|
||||
pageTheme,
|
||||
SupportButton,
|
||||
useApi,
|
||||
WarningPanel,
|
||||
} from '@backstage/core';
|
||||
import {
|
||||
Button,
|
||||
LinearProgress,
|
||||
Paper,
|
||||
Table,
|
||||
@@ -38,11 +39,10 @@ import {
|
||||
TableRow,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Button,
|
||||
} from '@material-ui/core';
|
||||
import React from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { GCPApiRef, Project } from '../../api';
|
||||
import { gcpApiRef, Project } from '../../api';
|
||||
|
||||
const LongText = ({ text, max }: { text: string; max: number }) => {
|
||||
if (text.length < max) {
|
||||
@@ -63,27 +63,17 @@ const labels = (
|
||||
);
|
||||
|
||||
const PageContents = () => {
|
||||
const api = useApi(GCPApiRef);
|
||||
const googleApi = useApi(googleAuthApiRef);
|
||||
const api = useApi(gcpApiRef);
|
||||
|
||||
const { loading, error, value } = useAsync(async () => {
|
||||
const token = await googleApi.getAccessToken(
|
||||
'https://www.googleapis.com/auth/cloud-platform.read-only',
|
||||
);
|
||||
|
||||
const projects = api.listProjects({ token });
|
||||
return projects;
|
||||
});
|
||||
const { loading, error, value } = useAsync(() => api.listProjects());
|
||||
|
||||
if (loading) {
|
||||
return <LinearProgress />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
} else if (error) {
|
||||
return (
|
||||
<Typography variant="h2" color="error">
|
||||
{error.message}{' '}
|
||||
</Typography>
|
||||
<WarningPanel title="Failed to load projects">
|
||||
{error.toString()}
|
||||
</WarningPanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,34 +15,43 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
createApiFactory,
|
||||
createPlugin,
|
||||
createRouteRef,
|
||||
createApiFactory,
|
||||
googleAuthApiRef,
|
||||
} from '@backstage/core';
|
||||
import { ProjectListPage } from './components/ProjectListPage';
|
||||
import { ProjectDetailsPage } from './components/ProjectDetailsPage';
|
||||
import { gcpApiRef, GcpClient } from './api';
|
||||
import { NewProjectPage } from './components/NewProjectPage';
|
||||
import { GCPApiRef, GCPClient } from './api';
|
||||
import { ProjectDetailsPage } from './components/ProjectDetailsPage';
|
||||
import { ProjectListPage } from './components/ProjectListPage';
|
||||
|
||||
export const rootRouteRef = createRouteRef({
|
||||
path: '/gcp-projects',
|
||||
title: 'GCP Projects',
|
||||
});
|
||||
export const ProjectRouteRef = createRouteRef({
|
||||
export const projectRouteRef = createRouteRef({
|
||||
path: '/gcp-projects/project',
|
||||
title: 'GCP Project Page',
|
||||
});
|
||||
export const NewProjectRouteRef = createRouteRef({
|
||||
export const newProjectRouteRef = createRouteRef({
|
||||
path: '/gcp-projects/new',
|
||||
title: 'GCP Project Page',
|
||||
});
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'gcp-projects',
|
||||
apis: [createApiFactory(GCPApiRef, new GCPClient())],
|
||||
apis: [
|
||||
createApiFactory({
|
||||
api: gcpApiRef,
|
||||
deps: { googleAuthApi: googleAuthApiRef },
|
||||
factory({ googleAuthApi }) {
|
||||
return new GcpClient(googleAuthApi);
|
||||
},
|
||||
}),
|
||||
],
|
||||
register({ router }) {
|
||||
router.addRoute(rootRouteRef, ProjectListPage);
|
||||
router.addRoute(ProjectRouteRef, ProjectDetailsPage);
|
||||
router.addRoute(NewProjectRouteRef, NewProjectPage);
|
||||
router.addRoute(projectRouteRef, ProjectDetailsPage);
|
||||
router.addRoute(newProjectRouteRef, NewProjectPage);
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user