diff --git a/microsite/pages/en/index.js b/microsite/pages/en/index.js index 37298ae8e2..6e649aa574 100644 --- a/microsite/pages/en/index.js +++ b/microsite/pages/en/index.js @@ -471,8 +471,8 @@ class Index extends React.Component { Cloud Native Computing Foundation {' '} sandbox project +
-
diff --git a/microsite/static/css/custom.css b/microsite/static/css/custom.css index 00a102ba00..79b8fd9de4 100644 --- a/microsite/static/css/custom.css +++ b/microsite/static/css/custom.css @@ -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; } diff --git a/package.json b/package.json index c64b7f6951..2622f1e322 100644 --- a/package.json +++ b/package.json @@ -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 .", diff --git a/packages/cli/src/commands/create-plugin/createPlugin.ts b/packages/cli/src/commands/create-plugin/createPlugin.ts index 8f75728544..37c026ecae 100644 --- a/packages/cli/src/commands/create-plugin/createPlugin.ts +++ b/packages/cli/src/commands/create-plugin/createPlugin.ts @@ -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) { diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index a0b898db75..fea250cd55 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -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 ', 'NPM scope') + .option('--npm-registry ', 'NPM registry URL') + .option('--no-private', 'Public NPM Package') .action( lazy(() => import('./create-plugin/createPlugin').then(m => m.default)), ); diff --git a/packages/cli/src/commands/plugin/diff.ts b/packages/cli/src/commands/plugin/diff.ts index 99af093f2c..f5dfa670b7 100644 --- a/packages/cli/src/commands/plugin/diff.ts +++ b/packages/cli/src/commands/plugin/diff.ts @@ -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 { 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 { const id = pluginIdMatch[1]; - return { id, name }; + return { id, name, privatePackage, version, npmRegistry }; } diff --git a/packages/cli/templates/default-backend-plugin/package.json.hbs b/packages/cli/templates/default-backend-plugin/package.json.hbs index fd2c57f87d..2ecdbcef01 100644 --- a/packages/cli/templates/default-backend-plugin/package.json.hbs +++ b/packages/cli/templates/default-backend-plugin/package.json.hbs @@ -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" + ] + } diff --git a/packages/cli/templates/default-plugin/package.json.hbs b/packages/cli/templates/default-plugin/package.json.hbs index 8adfb0d7bc..7597d3dc28 100644 --- a/packages/cli/templates/default-plugin/package.json.hbs +++ b/packages/cli/templates/default-plugin/package.json.hbs @@ -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" diff --git a/packages/core/package.json b/packages/core/package.json index 3ed730e0f3..440f9f05b0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index b95b5bbc5b..19e3891551 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -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": { diff --git a/packages/e2e-test/src/e2e-test.ts b/packages/e2e-test/src/e2e-test.ts index 146a3ec644..969a77cb2a 100644 --- a/packages/e2e-test/src/e2e-test.ts +++ b/packages/e2e-test/src/e2e-test.ts @@ -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() diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index db490cf542..ea99753c14 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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 ( { > Create Component + {showAddExampleEntities && ( + + )} All your software catalog entities
@@ -174,7 +191,6 @@ const CatalogPageContents = () => { entities={matchingEntities} loading={loading} error={error} - onAddMockData={addMockData} />
diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index d1eef2a005..46d6010f2f 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -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={() => {}} />, ), ); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index cd4df13775..f5885145b3 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -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; }; 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: () => , - tooltip: 'Add example components', - isFreeAction: true, - onClick: onAddMockData, - hidden: !(entities && entities.length === 0), - }, ]; return ( diff --git a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx index 94bc428a45..ceecc17953 100644 --- a/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx +++ b/plugins/catalog/src/filter/EntityFilterGroupsProvider.tsx @@ -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([]); const [availableTags, setAvailableTags] = useState([]); + const [isCatalogEmpty, setCatalogEmpty] = useState(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, }; } diff --git a/plugins/catalog/src/filter/context.ts b/plugins/catalog/src/filter/context.ts index 838ec8d532..c025480fa6 100644 --- a/plugins/catalog/src/filter/context.ts +++ b/plugins/catalog/src/filter/context.ts @@ -33,6 +33,7 @@ export type FilterGroupsContext = { filterGroupStates: { [filterGroupId: string]: FilterGroupStates }; matchingEntities: Entity[]; availableTags: string[]; + isCatalogEmpty: boolean; }; /** diff --git a/plugins/catalog/src/filter/useFilteredEntities.ts b/plugins/catalog/src/filter/useFilteredEntities.ts index 138a7547c0..2d7dcfd89d 100644 --- a/plugins/catalog/src/filter/useFilteredEntities.ts +++ b/plugins/catalog/src/filter/useFilteredEntities.ts @@ -31,6 +31,7 @@ export function useFilteredEntities() { error: context.error, matchingEntities: context.matchingEntities, availableTags: context.availableTags, + isCatalogEmpty: context.isCatalogEmpty, reload: context.reload, }; } diff --git a/plugins/gcp-projects/src/api/GCPClient.ts b/plugins/gcp-projects/src/api/GCPClient.ts deleted file mode 100644 index dc4fe21132..0000000000 --- a/plugins/gcp-projects/src/api/GCPClient.ts +++ /dev/null @@ -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 { - 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, - ): Promise { - 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 { - 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; - } -} diff --git a/plugins/gcp-projects/src/api/GCPApi.ts b/plugins/gcp-projects/src/api/GcpApi.ts similarity index 69% rename from plugins/gcp-projects/src/api/GCPApi.ts rename to plugins/gcp-projects/src/api/GcpApi.ts index 95b686815e..24ad1a93cc 100644 --- a/plugins/gcp-projects/src/api/GCPApi.ts +++ b/plugins/gcp-projects/src/api/GcpApi.ts @@ -17,18 +17,16 @@ import { createApiRef } from '@backstage/core'; import { Project, Operation } from './types'; -export const GCPApiRef = createApiRef({ +export const gcpApiRef = createApiRef({ id: 'plugin.gcpprojects.service', description: 'Used by the GCP Projects plugin to make requests', }); -export type GCPApi = { - listProjects: ({ token }: { token: string }) => Promise; - getProject: (projectId: string, token: Promise) => Promise; - createProject: ( - projectName: string, - projectId: string, - owner: string, - token: string, - ) => Promise; +export type GcpApi = { + listProjects(): Promise; + getProject(projectId: string): Promise; + createProject(options: { + projectId: string; + projectName: string; + }): Promise; }; diff --git a/plugins/gcp-projects/src/api/GcpClient.ts b/plugins/gcp-projects/src/api/GcpClient.ts new file mode 100644 index 0000000000..00bb859219 --- /dev/null +++ b/plugins/gcp-projects/src/api/GcpClient.ts @@ -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 { + 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 { + 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 { + 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 { + // 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', + ); + } +} diff --git a/plugins/gcp-projects/src/api/index.ts b/plugins/gcp-projects/src/api/index.ts index 51f617f19c..8e842c8e55 100644 --- a/plugins/gcp-projects/src/api/index.ts +++ b/plugins/gcp-projects/src/api/index.ts @@ -14,6 +14,6 @@ * limitations under the License. */ -export * from './GCPApi'; -export * from './GCPClient'; +export * from './GcpApi'; +export * from './GcpClient'; export * from './types'; diff --git a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx index 0149f8ac92..f03149bc1a 100644 --- a/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx +++ b/plugins/gcp-projects/src/components/NewProjectPage/NewProjectPage.tsx @@ -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); diff --git a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx index 41d0b11019..3dfb4e886b 100644 --- a/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectDetailsPage/ProjectDetailsPage.tsx @@ -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 => ({ root: { @@ -56,34 +56,27 @@ const useStyles = makeStyles(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 ; - } else if (status.error) { + } else if (error) { return ( - - Failed to load build, {status.error.message} - + + {error.toString()} + ); } - const details = status.value; - return (
diff --git a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx index 7e09a307c9..542d03d5fd 100644 --- a/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx +++ b/plugins/gcp-projects/src/components/ProjectListPage/ProjectListPage.tsx @@ -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 ; - } - - if (error) { + } else if (error) { return ( - - {error.message}{' '} - + + {error.toString()} + ); } diff --git a/plugins/gcp-projects/src/plugin.ts b/plugins/gcp-projects/src/plugin.ts index 41aacde597..2baf3bce7d 100644 --- a/plugins/gcp-projects/src/plugin.ts +++ b/plugins/gcp-projects/src/plugin.ts @@ -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); }, });