diff --git a/packages/app/package.json b/packages/app/package.json index 6e314c3fe0..8bfd6859db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "@backstage/plugin-catalog": "^0.1.1-alpha.7", "@backstage/plugin-circleci": "^0.1.1-alpha.7", "@backstage/plugin-explore": "^0.1.1-alpha.7", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7", "@backstage/plugin-home-page": "^0.1.1-alpha.7", "@backstage/plugin-lighthouse": "^0.1.1-alpha.7", "@backstage/plugin-register-component": "^0.1.1-alpha.7", diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index d348049157..ed0518485a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -18,7 +18,7 @@ import { createApp, AlertDisplay, OAuthRequestDialog } from '@backstage/core'; import React, { FC } from 'react'; import Root from './components/Root'; import * as plugins from './plugins'; -import apis from './apis'; +import { apis } from './apis'; import { hot } from 'react-hot-loader/root'; const app = createApp({ diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index ec0088e219..9bfc618216 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -15,11 +15,11 @@ */ import { - ApiHolder, ApiRegistry, alertApiRef, errorApiRef, AlertApiForwarder, + ConfigApi, ErrorApiForwarder, ErrorAlerter, featureFlagsApiRef, @@ -44,57 +44,66 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar'; import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci'; import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog'; -const builder = ApiRegistry.builder(); +import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles'; -const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); -const errorApi = builder.add( - errorApiRef, - new ErrorAlerter(alertApi, new ErrorApiForwarder()), -); +export const apis = (config: ConfigApi) => { + // eslint-disable-next-line no-console + console.log(`Creating APIs for ${config.getString('app.title')}`); -builder.add(storageApiRef, WebStorage.create({ errorApi })); -builder.add(circleCIApiRef, new CircleCIApi()); -builder.add(featureFlagsApiRef, new FeatureFlags()); + const builder = ApiRegistry.builder(); -builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); + const alertApi = builder.add(alertApiRef, new AlertApiForwarder()); + const errorApi = builder.add( + errorApiRef, + new ErrorAlerter(alertApi, new ErrorApiForwarder()), + ); -const oauthRequestApi = builder.add( - oauthRequestApiRef, - new OAuthRequestManager(), -); + builder.add(storageApiRef, WebStorage.create({ errorApi })); + builder.add(circleCIApiRef, new CircleCIApi()); + builder.add(featureFlagsApiRef, new FeatureFlags()); -builder.add( - googleAuthApiRef, - GoogleAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); + builder.add(lighthouseApiRef, new LighthouseRestApi('http://localhost:3003')); -builder.add( - githubAuthApiRef, - GithubAuth.create({ - apiOrigin: 'http://localhost:7000', - basePath: '/auth/', - oauthRequestApi, - }), -); + const oauthRequestApi = builder.add( + oauthRequestApiRef, + new OAuthRequestManager(), + ); -builder.add( - techRadarApiRef, - new TechRadar({ - width: 1500, - height: 800, - }), -); + builder.add( + googleAuthApiRef, + GoogleAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); -builder.add( - catalogApiRef, - new CatalogClient({ - apiOrigin: 'http://localhost:3000', - basePath: '/catalog/api', - }), -); + builder.add( + githubAuthApiRef, + GithubAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); -export default builder.build() as ApiHolder; + builder.add( + techRadarApiRef, + new TechRadar({ + width: 1500, + height: 800, + }), + ); + + builder.add( + catalogApiRef, + new CatalogClient({ + apiOrigin: 'http://localhost:3000', + basePath: '/catalog/api', + }), + ); + + builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')); + + return builder.build(); +}; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 579b88053f..8b1ce76cbd 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -19,6 +19,9 @@ import PropTypes from 'prop-types'; import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; import ExploreIcon from '@material-ui/icons/Explore'; +import BuildIcon from '@material-ui/icons/BuildRounded'; +import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; +import MapIcon from '@material-ui/icons/MyLocation'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; @@ -31,8 +34,9 @@ import { SidebarDivider, SidebarSearchField, SidebarSpace, - SidebarUserBadge, + SidebarUserSettings, SidebarThemeToggle, + SidebarPinButton, } from '@backstage/core'; import { NavLink } from 'react-router-dom'; @@ -87,10 +91,14 @@ const Root: FC<{}> = ({ children }) => ( {/* End global nav */} + + + - + + {children} diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 25a5bcfc9d..2cf2bde9d5 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; export { plugin as Sentry } from '@backstage/plugin-sentry'; +export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; diff --git a/packages/backend-common/package.json b/packages/backend-common/package.json index 94dd222c41..54202975c3 100644 --- a/packages/backend-common/package.json +++ b/packages/backend-common/package.json @@ -27,12 +27,17 @@ "clean": "backstage-cli clean" }, "dependencies": { + "compression": "^1.7.4", + "cors": "^2.8.5", "express": "^4.17.1", + "helmet": "^3.22.0", "morgan": "^1.10.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.7", + "@types/compression": "^1.7.0", + "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "@types/http-errors": "^1.6.3", "@types/morgan": "^1.9.0", diff --git a/packages/backend-common/src/index.ts b/packages/backend-common/src/index.ts index b2c38ab506..11689aafff 100644 --- a/packages/backend-common/src/index.ts +++ b/packages/backend-common/src/index.ts @@ -17,3 +17,4 @@ export * from './errors'; export * from './logging'; export * from './middleware'; +export * from './service'; diff --git a/packages/backend-common/src/service/ServiceBuilderImpl.ts b/packages/backend-common/src/service/ServiceBuilderImpl.ts new file mode 100644 index 0000000000..ac35d52112 --- /dev/null +++ b/packages/backend-common/src/service/ServiceBuilderImpl.ts @@ -0,0 +1,117 @@ +/* + * 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 compression from 'compression'; +import cors from 'cors'; +import express, { Router } from 'express'; +import helmet from 'helmet'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { getRootLogger } from '../logging'; +import { + errorHandler, + notFoundHandler, + requestLoggingHandler, +} from '../middleware'; +import { ServiceBuilder } from './types'; + +const DEFAULT_PORT = 7000; + +export class ServiceBuilderImpl implements ServiceBuilder { + private port: number | undefined; + private logger: Logger | undefined; + private corsOptions: cors.CorsOptions | undefined; + private routers: [string, Router][]; + + constructor() { + this.routers = []; + } + + setPort(port: number): ServiceBuilder { + this.port = port; + return this; + } + + setLogger(logger: Logger): ServiceBuilder { + this.logger = logger; + return this; + } + + enableCors(options: cors.CorsOptions): ServiceBuilder { + this.corsOptions = options; + return this; + } + + addRouter(root: string, router: Router): ServiceBuilder { + this.routers.push([root, router]); + return this; + } + + start(): Promise { + const app = express(); + const { port, logger, corsOptions } = this.getOptions(); + + app.use(helmet()); + if (corsOptions) { + app.use(cors(corsOptions)); + } + app.use(compression()); + app.use(express.json()); + app.use(requestLoggingHandler()); + for (const [root, route] of this.routers) { + app.use(root, route); + } + app.use(notFoundHandler()); + app.use(errorHandler()); + + return new Promise((resolve, reject) => { + app.on('error', e => { + logger.error(`Failed to start up on port ${port}, ${e}`); + reject(e); + }); + const server = app.listen(port, () => { + logger.info(`Listening on port ${port}`); + }); + resolve(server); + }); + } + + private getOptions(): { + port: number; + logger: Logger; + corsOptions?: cors.CorsOptions; + } { + let port: number; + if (this.port !== undefined) { + port = this.port; + } else { + port = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT; + } + + let logger: Logger; + if (this.logger) { + logger = this.logger; + } else { + logger = getRootLogger(); + } + + return { + port, + logger, + corsOptions: this.corsOptions, + }; + } +} diff --git a/plugins/catalog/src/data/component.ts b/packages/backend-common/src/service/createServiceBuilder.ts similarity index 74% rename from plugins/catalog/src/data/component.ts rename to packages/backend-common/src/service/createServiceBuilder.ts index 86749c6faa..ffd8901def 100644 --- a/plugins/catalog/src/data/component.ts +++ b/packages/backend-common/src/service/createServiceBuilder.ts @@ -13,12 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EntityMeta } from '@backstage/catalog-model'; -import { ReactNode } from 'react'; -export type Component = { - name: string; - kind: string; - metadata: EntityMeta; - description: ReactNode; -}; +import { ServiceBuilderImpl } from './ServiceBuilderImpl'; + +/** + * Creates a new service builder. + */ +export function createServiceBuilder() { + return new ServiceBuilderImpl(); +} diff --git a/packages/backend-common/src/service/index.ts b/packages/backend-common/src/service/index.ts new file mode 100644 index 0000000000..9bba9d6baf --- /dev/null +++ b/packages/backend-common/src/service/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { createServiceBuilder } from './createServiceBuilder'; +export type { ServiceBuilder } from './types'; diff --git a/packages/backend-common/src/service/types.ts b/packages/backend-common/src/service/types.ts new file mode 100644 index 0000000000..306f0d587e --- /dev/null +++ b/packages/backend-common/src/service/types.ts @@ -0,0 +1,65 @@ +/* + * 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 cors from 'cors'; +import { Router } from 'express'; +import { Server } from 'http'; +import { Logger } from 'winston'; + +export type ServiceBuilder = { + /** + * Sets the port to listen on. + * + * If no port is specified, the service will first look for an environment + * variable named PORT and use that if present, otherwise it picks a default + * port (7000). + * + * @param port The port to listen on + */ + setPort(port: number): ServiceBuilder; + + /** + * Sets the logger to use for service-specific logging. + * + * If no logger is given, the default root logger is used. + * + * @param logger A winston logger + */ + setLogger(logger: Logger): ServiceBuilder; + + /** + * Enables CORS handling using the given settings. + * + * If this method is not called, the resulting service will not have any + * built in CORS handling. + * + * @param options Standard CORS options + */ + enableCors(options: cors.CorsOptions): ServiceBuilder; + + /** + * Adds a router (similar to the express .use call) to the service. + * + * @param root The root URL to bind to (e.g. "/api/function1") + * @param router An express router + */ + addRouter(root: string, router: Router): ServiceBuilder; + + /** + * Starts the server using the given settings. + */ + start(): Promise; +}; diff --git a/packages/backend/README.md b/packages/backend/README.md index 48dd7f09fe..64078006e7 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location to the backend. These are places where the backend can find some entity descriptor data to consume and serve. -To get started, you can issue the following after starting the backend: +To get started, you can issue the following after starting the backend, from inside +the `plugins/catalog-backend` directory: ```bash -curl -i \ - -H "Content-Type: application/json" \ - -d '{"type":"github","target":"https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"}' \ - localhost:7000/catalog/locations +yarn mock-catalog-data ``` -After a short while, you should start seeing data on `localhost:7000/catalog/entities`. - -If you changed the `type` to `file` in the command above, and set the `target` -to the absolute path of a YAML file on disk, you could consume your own experimental data. +You should then start seeing data on `localhost:7000/catalog/entities`. The catalog currently runs in-memory only, so feel free to try it out, but it will need to be re-populated on next startup. diff --git a/packages/backend/package.json b/packages/backend/package.json index 65e2976489..4e2610380c 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -24,19 +24,14 @@ "@backstage/plugin-identity-backend": "^0.1.1-alpha.7", "@backstage/plugin-scaffolder-backend": "^0.1.1-alpha.7", "@backstage/plugin-sentry-backend": "^0.1.1-alpha.7", - "compression": "^1.7.4", - "cors": "^2.8.5", "esm": "^3.2.25", "express": "^4.17.1", - "helmet": "^3.22.0", "knex": "^0.21.1", "sqlite3": "^4.2.0", "winston": "^3.2.1" }, "devDependencies": { "@backstage/cli": "^0.1.1-alpha.7", - "@types/compression": "^1.7.0", - "@types/cors": "^2.8.6", "@types/express": "^4.17.6", "@types/express-serve-static-core": "^4.17.5", "@types/helmet": "^0.0.47", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 5b1fc54330..0f0733a8ec 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -22,27 +22,15 @@ * Happy hacking! */ -import { - errorHandler, - getRootLogger, - notFoundHandler, - requestLoggingHandler, -} from '@backstage/backend-common'; -import compression from 'compression'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; +import { createServiceBuilder, getRootLogger } from '@backstage/backend-common'; import knex from 'knex'; +import auth from './plugins/auth'; import catalog from './plugins/catalog'; +import identity from './plugins/identity'; import scaffolder from './plugins/scaffolder'; import sentry from './plugins/sentry'; -import auth from './plugins/auth'; -import identity from './plugins/identity'; import { PluginEnvironment } from './types'; -const DEFAULT_PORT = 7000; -const PORT = parseInt(process.env.PORT ?? '', 10) || DEFAULT_PORT; - function createEnv(plugin: string): PluginEnvironment { const logger = getRootLogger().child({ type: 'plugin', plugin }); const database = knex({ @@ -57,30 +45,23 @@ function createEnv(plugin: string): PluginEnvironment { } async function main() { - const app = express(); - const corsOptions: cors.CorsOptions = { - origin: 'http://localhost:3000', - credentials: true, - }; + const service = createServiceBuilder() + .enableCors({ + origin: 'http://localhost:3000', + credentials: true, + }) + .addRouter('/catalog', await catalog(createEnv('catalog'))) + .addRouter('/scaffolder', await scaffolder(createEnv('scaffolder'))) + .addRouter( + '/sentry', + await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })), + ) + .addRouter('/auth', await auth(createEnv('auth'))) + .addRouter('/identity', await identity(createEnv('identity'))); - app.use(helmet()); - app.use(cors(corsOptions)); - app.use(compression()); - app.use(express.json()); - app.use(requestLoggingHandler()); - app.use('/catalog', await catalog(createEnv('catalog'))); - app.use('/scaffolder', await scaffolder(createEnv('scaffolder'))); - app.use( - '/sentry', - await sentry(getRootLogger().child({ type: 'plugin', plugin: 'sentry' })), - ); - app.use('/auth', await auth(createEnv('auth'))); - app.use('/identity', await identity(createEnv('identity'))); - app.use(notFoundHandler()); - app.use(errorHandler()); - - app.listen(PORT, () => { - getRootLogger().info(`Listening on port ${PORT}`); + await service.start().catch(err => { + console.log(err); + process.exit(1); }); } diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 4cbc8bcfb1..7abab45780 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '../ApiRef'; +import { Observable } from '../..'; /** * This file contains declarations for common interfaces of auth-related APIs. @@ -167,6 +168,14 @@ export type ProfileInfo = { picture?: string; }; +export enum SessionState { + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +export type SessionStateApi = { + sessionState$(): Observable; +}; /** * Provides authentication towards Google APIs and identities. * @@ -176,7 +185,7 @@ export type ProfileInfo = { * email and expiration information. Do not rely on any other fields, as they might not be present. */ export const googleAuthApiRef = createApiRef< - OAuthApi & OpenIdConnectApi & ProfileInfoApi + OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi >({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', @@ -188,7 +197,7 @@ export const googleAuthApiRef = createApiRef< * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ * for a full list of supported scopes. */ -export const githubAuthApiRef = createApiRef({ +export const githubAuthApiRef = createApiRef({ id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index d75bceef97..ac05b718e5 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -17,10 +17,17 @@ import GithubIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; -import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth'; +import { + OAuthApi, + AccessTokenOptions, + SessionStateApi, + SessionState, +} from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth @@ -46,7 +53,7 @@ const DEFAULT_PROVIDER = { icon: GithubIcon, }; -class GithubAuth implements OAuthApi { +class GithubAuth implements OAuthApi, SessionStateApi { static create({ apiOrigin, basePath, @@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi { return new GithubAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken(scope?: string, options?: AccessTokenOptions) { @@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } static normalizeScope(scope?: string): Set { diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index d65a2c71ee..1fc6f4f6b8 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -25,10 +25,14 @@ import { ProfileInfoApi, ProfileInfoOptions, ProfileInfo, + SessionStateApi, + SessionState, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth @@ -57,7 +61,8 @@ const DEFAULT_PROVIDER = { const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { +class GoogleAuth + implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { static create({ apiOrigin, basePath, @@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { return new GoogleAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken( @@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async getIdToken(options: IdTokenOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.idToken; } @@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } async getProfile(options: ProfileInfoOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (!session) { return undefined; } diff --git a/packages/core-api/src/app/App.tsx b/packages/core-api/src/app/App.tsx index 7cc37c2544..972acbd8ea 100644 --- a/packages/core-api/src/app/App.tsx +++ b/packages/core-api/src/app/App.tsx @@ -17,7 +17,7 @@ import React, { ComponentType, FC, useMemo } from 'react'; import { Route, Switch, Redirect } from 'react-router-dom'; import { AppContextProvider } from './AppContext'; -import { BackstageApp, AppComponents, AppConfigLoader } from './types'; +import { BackstageApp, AppComponents, AppConfigLoader, Apis } from './types'; import { BackstagePlugin } from '../plugin'; import { FeatureFlagsRegistryItem } from './FeatureFlags'; import { featureFlagsApiRef } from '../apis/definitions'; @@ -38,7 +38,7 @@ import { ApiAggregator } from '../apis/ApiAggregator'; import { useAsync } from 'react-use'; type FullAppOptions = { - apis: ApiHolder; + apis: Apis; icons: SystemIcons; plugins: BackstagePlugin[]; components: AppComponents; @@ -47,15 +47,17 @@ type FullAppOptions = { }; export class PrivateAppImpl implements BackstageApp { - private readonly apis: ApiHolder; + private apis?: ApiHolder = undefined; private readonly icons: SystemIcons; private readonly plugins: BackstagePlugin[]; private readonly components: AppComponents; private readonly themes: AppTheme[]; private readonly configLoader?: AppConfigLoader; + private apisOrFactory: Apis; + constructor(options: FullAppOptions) { - this.apis = options.apis; + this.apisOrFactory = options.apis; this.icons = options.icons; this.plugins = options.plugins; this.components = options.components; @@ -64,6 +66,9 @@ export class PrivateAppImpl implements BackstageApp { } getApis(): ApiHolder { + if (!this.apis) { + throw new Error('Tried to access APIs before app was loaded'); + } return this.apis; } @@ -196,6 +201,15 @@ export class PrivateAppImpl implements BackstageApp { [appThemeApiRef, AppThemeSelector.createWithStorage(this.themes)], [configApiRef, configReader], ]); + + if (!this.apis) { + if ('get' in this.apisOrFactory) { + this.apis = this.apisOrFactory; + } else { + this.apis = this.apisOrFactory(configReader); + } + } + const apis = new ApiAggregator(this.apis, appApis); const { Router } = this.components; diff --git a/packages/core-api/src/app/types.ts b/packages/core-api/src/app/types.ts index 664c5238bc..44201d1464 100644 --- a/packages/core-api/src/app/types.ts +++ b/packages/core-api/src/app/types.ts @@ -18,7 +18,7 @@ import { ComponentType } from 'react'; import { IconComponent, SystemIconKey, SystemIcons } from '../icons'; import { BackstagePlugin } from '../plugin'; import { ApiHolder } from '../apis'; -import { AppTheme } from '../apis/definitions'; +import { AppTheme, ConfigApi } from '../apis/definitions'; import { AppConfig } from '@backstage/config'; export type BootErrorPageProps = { @@ -41,13 +41,16 @@ export type AppComponents = { */ export type AppConfigLoader = () => Promise; +// TODO(Rugvip): Temporary workaround for accessing config when instantiating APIs, we might want to do this differently +export type Apis = ApiHolder | ((config: ConfigApi) => ApiHolder); + export type AppOptions = { /** * A holder of all APIs available in the app. * * Use for example ApiRegistry or ApiTestRegistry. */ - apis?: ApiHolder; + apis?: Apis; /** * Supply icons to override the default ones. diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index ee7cbab2ae..793c6f1708 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -131,15 +131,6 @@ describe('RefreshingAuthSessionManager', () => { }); it('should remove session and reload', async () => { - // This is a workaround that is used by Facebook and the Jest core team - // It is a limitation with the newest versions of JSDOM, and newer browser standards - // where window.location and all of its properties are read-only. So we re-construct it! - // See https://github.com/facebook/jest/issues/890#issuecomment-209698782 - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new RefreshingAuthSessionManager({ connector: { removeSession }, @@ -147,7 +138,7 @@ describe('RefreshingAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 3df21af3f3..f7d5bcf7ca 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } async getCurrentSession() { diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..de308acb0c --- /dev/null +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -0,0 +1,32 @@ +/* + * 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 { BehaviorSubject } from '..'; +import { SessionState } from '../../apis'; + +export class SessionStateTracker { + private signedIn: boolean = false; + observable = new BehaviorSubject(SessionState.SignedOut); + + setIsSignedId(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.observable.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } +} diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts index 7d47fa91df..6280750875 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -84,11 +84,6 @@ describe('StaticAuthSessionManager', () => { }); it('should remove session and reload', async () => { - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new StaticAuthSessionManager({ connector: { removeSession }, @@ -96,7 +91,7 @@ describe('StaticAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index 6e6db47a99..5ecbbc0c4e 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -64,7 +64,7 @@ export class StaticAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } } diff --git a/packages/core/src/components/Tabs/Tab.test.tsx b/packages/core/src/components/Tabs/Tab.test.tsx new file mode 100644 index 0000000000..5b155ef536 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.test.tsx @@ -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 from 'react'; +import { render } from '@testing-library/react'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { StyledTab } from './Tab'; + +describe('', () => { + it('renders without exploding', () => { + const rendered = render(wrapInTestApp()); + expect(rendered.getByText('test')).toBeInTheDocument(); + }); +}); diff --git a/packages/core/src/components/Tabs/Tab.tsx b/packages/core/src/components/Tabs/Tab.tsx new file mode 100644 index 0000000000..93243b2900 --- /dev/null +++ b/packages/core/src/components/Tabs/Tab.tsx @@ -0,0 +1,62 @@ +/* + * 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 { Tab, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledTabProps { + label?: string; + icon?: any; // TODO: define type for material-ui icons + isFirstNav?: boolean; + isFirstIndex?: boolean; + value?: any; +} + +const tabMarginLeft = (isFirstNav: boolean, isFirstIndex: boolean) => { + if (isFirstIndex) { + if (isFirstNav) { + return '20px'; + } + return '0'; + } + return '40px'; +}; + +const useStyles = makeStyles(theme => ({ + root: { + textTransform: 'none', + height: '64px', + fontWeight: theme.typography.fontWeightBold, + fontSize: theme.typography.pxToRem(13), + color: theme.palette.textSubtle, + marginLeft: props => + tabMarginLeft(props.isFirstNav as boolean, props.isFirstIndex as boolean), + width: '130px', + minWidth: '130px', + '&:hover': { + outline: 'none', + backgroundColor: 'transparent', + color: theme.palette.textSubtle, + }, + }, +})); + +export const StyledTab = (props: StyledTabProps) => { + const classes = useStyles(props); + const { isFirstNav, isFirstIndex, ...rest } = props; + return ; +}; diff --git a/packages/core/src/components/Tabs/TabBar.tsx b/packages/core/src/components/Tabs/TabBar.tsx new file mode 100644 index 0000000000..379d60668b --- /dev/null +++ b/packages/core/src/components/Tabs/TabBar.tsx @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { Tabs, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledTabsProps { + value: number | boolean; + onChange: (event: React.ChangeEvent<{}>, newValue: number) => void; +} + +const useStyles = makeStyles(theme => ({ + indicator: { + display: 'flex', + justifyContent: 'center', + backgroundColor: theme.palette.tabbar.indicator, + height: '4px', + }, + flexContainer: { + alignItems: 'center', + }, + root: { + '&:last-child': { + marginLeft: 'auto', + }, + }, +})); + +export const StyledTabs: FC = props => { + const classes = useStyles(props); + return ( + }} + /> + ); +}; diff --git a/packages/core/src/components/Tabs/TabIcon.tsx b/packages/core/src/components/Tabs/TabIcon.tsx new file mode 100644 index 0000000000..ffb2e12cbd --- /dev/null +++ b/packages/core/src/components/Tabs/TabIcon.tsx @@ -0,0 +1,60 @@ +/* + * 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 { IconButton, makeStyles } from '@material-ui/core'; +import { BackstageTheme } from '@backstage/theme'; + +interface StyledIconProps { + ariaLabel: string; + children: any; + isNext?: boolean; + onClick: any; +} + +const useStyles = makeStyles(() => ({ + root: { + color: '#6E6E6E', + overflow: 'visible', + fontSize: '1.5rem', + textAlign: 'center', + borderRadius: '50%', + backgroundColor: '#E6E6E6', + marginLeft: props => (props.isNext ? 'auto' : '0'), + marginRight: props => (props.isNext ? '0' : '10px'), + '&:hover': { + backgroundColor: '#E6E6E6', + opacity: '1', + }, + }, +})); + +export const StyledIcon = (props: StyledIconProps) => { + const classes = useStyles(props); + const { ariaLabel, onClick } = props; + return ( + + {props.children} + + ); +}; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/packages/core/src/components/Tabs/TabPanel.tsx similarity index 51% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx rename to packages/core/src/components/Tabs/TabPanel.tsx index 7059709992..ba8ca4bdee 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/packages/core/src/components/Tabs/TabPanel.tsx @@ -13,32 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React, { FC } from 'react'; -import { Component } from '../../data/component'; -import { Progress, InfoCard, StructuredMetadataTable } from '@backstage/core'; -type ComponentMetadataCardProps = { - loading: boolean; - component: Component | undefined; -}; -const ComponentMetadataCard: FC = ({ - loading, - component, -}) => { - if (loading) { - return ( - - - - ); - } - if (!component) { - return null; - } +import React, { FC } from 'react'; +import Box from '@material-ui/core/Box'; + +export interface TabPanelProps { + children: any; + value?: any; + index?: number; +} + +export const TabPanel: FC = props => { + const { children, value, index, ...other } = props; + return ( - - - + ); }; -export default ComponentMetadataCard; diff --git a/packages/core/src/components/Tabs/Tabs.stories.tsx b/packages/core/src/components/Tabs/Tabs.stories.tsx new file mode 100644 index 0000000000..930a825392 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.stories.tsx @@ -0,0 +1,71 @@ +/* + * 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 { Tabs } from './Tabs'; +import AccessAlarmIcon from '@material-ui/icons/AccessAlarm'; + +export default { + title: 'Tabs', + component: Tabs, +}; + +const containerStyle = {}; + +export const Default = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Expandable = () => ( +
+ ({ + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); + +export const Icons = () => ( +
+ ({ + icon: , + content:
Content {index}
, + }))} + /> +
+); + +export const IconsAndLabels = () => ( +
+ ({ + icon: , + label: `ANOTHER TAB`, + content:
Content {index}
, + }))} + /> +
+); diff --git a/packages/core/src/components/Tabs/Tabs.tsx b/packages/core/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000000..54369de473 --- /dev/null +++ b/packages/core/src/components/Tabs/Tabs.tsx @@ -0,0 +1,165 @@ +/* + * 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, { + FC, + useRef, + useEffect, + MutableRefObject, + useState, +} from 'react'; +import { BackstageTheme } from '@backstage/theme'; +import { AppBar } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; +import NavigateBeforeIcon from '@material-ui/icons/NavigateBefore'; +import NavigateNextIcon from '@material-ui/icons/NavigateNext'; +import { chunkArray } from './utils'; +import { useWindowSize } from 'react-use'; + +/* Import Components */ + +import { TabPanel } from './TabPanel'; +import { StyledIcon } from './TabIcon'; +import { StyledTab } from './Tab'; +import { StyledTabs } from './TabBar'; + +/* Props Types */ + +export interface TabProps { + content: any; + label?: string; + icon?: any; // TODO: define type for material-ui icons +} + +export interface TabsProps { + tabs: TabProps[]; +} + +const useStyles = makeStyles((theme: BackstageTheme) => ({ + root: { + flexGrow: 1, + width: '100%', + }, + styledTabs: { + backgroundColor: theme.palette.background.paper, + }, + appbar: { + boxShadow: 'none', + backgroundColor: theme.palette.background.paper, + paddingLeft: '10px', + paddingRight: '10px', + }, +})); + +export const Tabs: FC = ({ tabs }) => { + const classes = useStyles(); + const [value, setValue] = useState([0, 0]); // [selectedChunckedNavIndex, selectedIndex] + const [navIndex, setNavIndex] = useState(0); + const [numberOfChunkedElement, setNumberOfChunkedElement] = useState(0); + const [chunkedTabs, setChunkedTabs] = useState([[]]); + const wrapper = useRef() as MutableRefObject; + + const { width } = useWindowSize(); + + const handleChange = (_: React.ChangeEvent<{}>, newValue: number) => { + setValue([navIndex, newValue]); + }; + + const navigateToPrevChunk = () => { + setNavIndex(navIndex - 1); + }; + + const navigateToNextChunk = () => { + setNavIndex(navIndex + 1); + }; + + const hasNextNavIndex = () => navIndex + 1 < chunkedTabs.length; + + useEffect(() => { + // Each time the window is resized we calculate how many tabs wwe can render given the window width + const padding = 20; // The AppBar padding + + const numberOfTabIcons = navIndex === 0 ? 1 : 2; + const wrapperWidth = + wrapper.current.offsetWidth - padding - numberOfTabIcons * 30; + const flattenIndex = value[0] * numberOfChunkedElement + value[1]; + const newChunkedElementSize = Math.floor(wrapperWidth / 170); + + setNumberOfChunkedElement(newChunkedElementSize); + setChunkedTabs(chunkArray([...tabs], newChunkedElementSize)); + setValue([ + Math.floor(flattenIndex / newChunkedElementSize), + flattenIndex % newChunkedElementSize, + ]); + // eslint-disable-next-line + }, [width, tabs]); + + const currentIndex = navIndex === value[0] ? value[1] : false; + + return ( +
+ +
+ + {navIndex !== 0 && ( + + + + )} + {chunkedTabs[navIndex].map((tab, index) => ( + + ))} + {hasNextNavIndex() && ( + + + + )} + +
+
+ {currentIndex !== false ? ( + chunkedTabs[navIndex].map((tab, index) => ( + + {tab.content} + + )) + ) : ( + // Render if the selected tab index is outside the current rendered chunked array + + {chunkedTabs[value[0]][value[1]].content} + + )} +
+ ); +}; diff --git a/packages/core/src/components/Tabs/index.ts b/packages/core/src/components/Tabs/index.ts new file mode 100644 index 0000000000..835ab5a8c3 --- /dev/null +++ b/packages/core/src/components/Tabs/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Tabs as default } from './Tabs'; diff --git a/packages/core/src/components/Tabs/utils.ts b/packages/core/src/components/Tabs/utils.ts new file mode 100644 index 0000000000..3e0ab6f2c3 --- /dev/null +++ b/packages/core/src/components/Tabs/utils.ts @@ -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 { TabProps } from './Tabs'; + +export const chunkArray = ( + myArray: TabProps[], + chunkSize: number, +): TabProps[][] => { + const results = []; + while (myArray.length) { + results.push(myArray.splice(0, chunkSize)); + } + return results; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f3c5c5c778..c5749866b6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -41,3 +41,4 @@ export * from './components/Status'; export * from './components/Button'; export * from './components/Link'; export { default as WarningPanel } from './components/WarningPanel'; +export { default as Tabs } from './components/Tabs'; diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index ddd71cc189..4c7d4487da 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -58,7 +58,11 @@ const useStyles = makeStyles(theme => { // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs fontWeight: 'bold', whiteSpace: 'nowrap', - lineHeight: 1.0, + lineHeight: 'auto', + flex: '3 1 auto', + width: '110px', + overflow: 'hidden', + 'text-overflow': 'ellipsis', }, iconContainer: { boxSizing: 'border-box', @@ -84,6 +88,11 @@ const useStyles = makeStyles(theme => { searchContainer: { width: drawerWidthOpen - iconContainerWidth, }, + secondaryAction: { + width: theme.spacing(6), + textAlign: 'center', + marginRight: theme.spacing(1), + }, selected: { '&$root': { borderLeft: `solid ${selectedIndicatorWidth}px #9BF0E1`, @@ -148,7 +157,6 @@ export const SidebarItem: FC = ({ ); } - return ( = ({ {text} )} - {children} +
{children}
); }; diff --git a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx b/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx deleted file mode 100644 index d0c3dc62c2..0000000000 --- a/packages/core/src/layout/Sidebar/LoggedUserBadge.tsx +++ /dev/null @@ -1,298 +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 React, { FC, useState, useEffect } from 'react'; -import { makeStyles, Theme } from '@material-ui/core/styles'; -import { sidebarConfig } from './config'; -import { - Avatar, - ListItem, - ListItemAvatar, - ListItemText, - Popover, - List, - ListItemIcon, - ListItemSecondaryAction, - IconButton, - Tooltip, - Typography, -} from '@material-ui/core'; -import { blueGrey } from '@material-ui/core/colors'; -import { useSetState } from 'react-use'; -import { Skeleton } from '@material-ui/lab'; -import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api'; -import LogoutIcon from '@material-ui/icons/PowerSettingsNew'; -import ControlPointIcon from '@material-ui/icons/ControlPoint'; -import AccountCircleIcon from '@material-ui/icons/AccountCircle'; - -const useStyles = makeStyles(theme => { - const { drawerWidthOpen, userBadgeDiameter } = sidebarConfig; - return { - root: { - width: drawerWidthOpen, - display: 'flex', - alignItems: 'center', - paddingLeft: 18, - paddingTop: 14, - paddingBottom: 14, - color: '#b5b5b5', - }, - avatar: { - width: userBadgeDiameter, - height: userBadgeDiameter, - marginRight: 8, - }, - purple: { - color: theme.palette.getContrastText(blueGrey[500]), - backgroundColor: blueGrey[500], - }, - listItemText: { - overflow: 'hidden', - textOverflow: 'ellipsis', - }, - }; -}); - -const SessionListItem: FC<{ - classes: any; - loading: boolean; - title: string; - icon: any; - user: any; - onSignIn: Function; - onSignOut: Function; -}> = ({ - classes, - loading, - title, - icon, - user, - onSignIn, - onSignOut, - ...props -}) => { - if (loading) { - return ( - - - - - } - secondary={} - /> - - - - - - - ); - } - - // TODO: Not functional yet to sign in from the sidebar - if (!user) { - return ( - - {icon} - - - - onSignIn()}> - - - - - - ); - } - - const { id, avatarUrl, avatarAlt } = user; - - return ( - - - - {avatarAlt && avatarAlt[0].toUpperCase()} - - - - {id} - - } - secondary={title} - /> - - - onSignOut()}> - - - - - - ); -}; - -const useGoogleLoginState = (open: boolean) => { - const googleAuth = useApi(googleAuthApiRef); - const [loading, setLoading] = useState(true); - const [profile, setProfile] = useState(); - - useEffect(() => { - let didCancel = false; - - if (open) { - googleAuth.getProfile().then(_profile => { - if (!didCancel) { - setProfile(_profile); - setLoading(false); - } - }); - } - - return () => { - didCancel = true; - }; - }, [open, googleAuth]); - - if (loading) { - return { loading: true }; - } - return { loading: false, isLoggedIn: !!profile, profile }; -}; - -type Props = { - email: string; - imageUrl?: string; - name?: string; - collapsedMode?: boolean; -}; - -export const LoggedUserBadge: FC = ({ - imageUrl, - name, - email, - collapsedMode = false, -}) => { - const [state, setState] = useSetState({ - open: false, - anchorEl: null, - }); - const googleAuth = useApi(googleAuthApiRef); - const googleLogin = useGoogleLoginState(state.open); - - const handleOpen = (event: { - preventDefault: () => void; - currentTarget: any; - }) => { - // This prevents ghost click. - event.preventDefault(); - setState({ - open: true, - anchorEl: event.currentTarget, - }); - }; - - const handleClose = () => { - setState({ - open: false, - }); - }; - - const handleGoogleSignIn = () => { - googleAuth.getIdToken(); - handleClose(); - }; - - const handleGoogleSignOut = () => { - googleAuth.logout(); - }; - - const classes = useStyles(); - const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1); - const emailTrimmed = email.split('@')[0]; - const displayEmail = - emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1); - const displayName = name ?? displayEmail; - - return ( - <> - - - - {imageUrl ? ( - - ) : ( - - {avatarFallback[0]} - - )} - - {!collapsedMode && ( - - {displayName} - - } - /> - )} - - - - - - - - - ); -}; diff --git a/packages/core/src/layout/Sidebar/UserBadge.tsx b/packages/core/src/layout/Sidebar/PinButton.tsx similarity index 61% rename from packages/core/src/layout/Sidebar/UserBadge.tsx rename to packages/core/src/layout/Sidebar/PinButton.tsx index d3e02d26cc..8c52eeb24d 100644 --- a/packages/core/src/layout/Sidebar/UserBadge.tsx +++ b/packages/core/src/layout/Sidebar/PinButton.tsx @@ -14,35 +14,32 @@ * limitations under the License. */ -import React, { FC, useContext, useEffect, useState } from 'react'; +import React, { FC, useContext } from 'react'; import { makeStyles } from '@material-ui/core'; -import AccountCircleIcon from '@material-ui/icons/AccountCircle'; -import { SidebarContext } from './config'; -import { SidebarItem } from './Items'; -import { LoggedUserBadge } from './LoggedUserBadge'; import DoubleArrowIcon from '@material-ui/icons/DoubleArrow'; +import { SidebarContext } from './config'; import { BackstageTheme } from '@backstage/theme'; import { SidebarPinStateContext } from './Page'; -import { useApi, googleAuthApiRef, ProfileInfo } from '@backstage/core-api'; const ARROW_BUTTON_SIZE = 20; const useStyles = makeStyles(theme => { return { root: { position: 'relative', + alignSelf: 'stretch', }, arrowButtonWrapper: { position: 'absolute', right: 0, width: ARROW_BUTTON_SIZE, height: ARROW_BUTTON_SIZE, - top: `calc(50% - ${ARROW_BUTTON_SIZE / 2}px)`, + top: -(theme.spacing(6) + ARROW_BUTTON_SIZE) / 2, display: 'flex', alignItems: 'center', justifyContent: 'center', borderRadius: '2px 0px 0px 2px', - background: theme.palette.pinSidebarButton.icon, - color: theme.palette.pinSidebarButton.background, + background: theme.palette.pinSidebarButton.background, + color: theme.palette.pinSidebarButton.icon, border: 'none', outline: 'none', cursor: 'pointer', @@ -53,37 +50,15 @@ const useStyles = makeStyles(theme => { }; }); -export const SidebarUserBadge: FC<{}> = () => { +export const SidebarPinButton: FC<{}> = () => { const { isOpen } = useContext(SidebarContext); const { isPinned, toggleSidebarPinState } = useContext( SidebarPinStateContext, ); const classes = useStyles({ isPinned }); - const googleAuth = useApi(googleAuthApiRef); - const [profile, setProfile] = useState(); - - useEffect(() => { - // TODO(soapraj): How to observe if the user is logged in - // TODO(soapraj): List all the providers supported by the app and let user log in from here - googleAuth.getProfile({ optional: true }).then(googleProfile => { - setProfile(googleProfile); - }); - }, [googleAuth]); return (
- {profile ? ( - <> - - - ) : ( - - )} {isOpen && ( - All your components + All your software catalog entities
@@ -182,16 +195,7 @@ const CatalogPage: FC<{}> = () => {
{ - return { - ...entityToComponent(val), - locationSpec: findLocationForEntityMeta(val.metadata), - }; - })) || - [] - } + entities={value || []} loading={loading} error={error} actions={actions} @@ -201,5 +205,3 @@ const CatalogPage: FC<{}> = () => { ); }; - -export default CatalogPage; diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 41693188f1..c04e4c95a8 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -13,30 +13,28 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import * as React from 'react'; -import { render } from '@testing-library/react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import CatalogTable from './CatalogTable'; -import { Component } from '../../data/component'; -const components: Component[] = [ +import { Entity } from '@backstage/catalog-model'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { render } from '@testing-library/react'; +import * as React from 'react'; +import { CatalogTable } from './CatalogTable'; + +const entites: Entity[] = [ { - name: 'component1', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component1' }, - description: 'Placeholder', }, { - name: 'component2', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component2' }, - description: 'Placeholder', }, { - name: 'component3', + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'component3' }, - description: 'Placeholder', }, ]; @@ -46,30 +44,30 @@ describe('CatalogTable component', () => { wrapInTestApp( , ), ); const errorMessage = await rendered.findByText( - /Error encountered while fetching components./, + /Error encountered while fetching catalog entities./, ); expect(errorMessage).toBeInTheDocument(); }); - it('should display component names when loading has finished and no error occurred', async () => { + it('should display entity names when loading has finished and no error occurred', async () => { const rendered = render( wrapInTestApp( , ), ); expect( - await rendered.findByText(`Owned (${components.length})`), + await rendered.findByText(`Owned (${entites.length})`), ).toBeInTheDocument(); expect(await rendered.findByText('component1')).toBeInTheDocument(); expect(await rendered.findByText('component2')).toBeInTheDocument(); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index cd91838b06..f62749ee17 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -13,49 +13,61 @@ * 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'; import { Alert } from '@material-ui/lab'; import React, { FC } from 'react'; -import { Link as RouterLink, generatePath } from 'react-router-dom'; -import { Component } from '../../data/component'; - +import { generatePath, Link as RouterLink } from 'react-router-dom'; import { entityRoute } from '../../routes'; const columns: TableColumn[] = [ { title: 'Name', - field: 'name', + field: 'metadata.name', highlight: true, - render: (componentData: any) => ( + render: (entity: any) => ( - {componentData.name} + {entity.metadata.name} ), }, { - title: 'Kind', - field: 'kind', + title: 'Owner', + field: 'spec.owner', + }, + { + title: 'Lifecycle', + field: 'spec.lifecycle', }, { title: 'Description', - field: 'description', + field: 'metadata.description', }, ]; type CatalogTableProps = { - components: Component[]; + entities: Entity[]; titlePreamble: string; loading: boolean; error?: any; actions?: any; }; -const CatalogTable: FC = ({ - components, +export const CatalogTable: FC = ({ + entities, loading, error, titlePreamble, @@ -65,7 +77,7 @@ const CatalogTable: FC = ({ return (
- Error encountered while fetching components. {error.toString()} + Error encountered while fetching catalog entities. {error.toString()}
); @@ -81,11 +93,9 @@ const CatalogTable: FC = ({ loadingType: 'linear', showEmptyDataSourceMessage: !loading, }} - title={`${titlePreamble} (${(components && components.length) || 0})`} - data={components} + title={`${titlePreamble} (${(entities && entities.length) || 0})`} + data={entities} actions={actions} /> ); }; - -export default CatalogTable; diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx deleted file mode 100644 index f26c21b6ca..0000000000 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ /dev/null @@ -1,152 +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 React, { FC, useEffect, useState } from 'react'; -import { useAsync } from 'react-use'; -import ComponentMetadataCard from '../ComponentMetadataCard/ComponentMetadataCard'; -import { - Content, - Header, - pageTheme, - Page, - useApi, - ErrorApi, - errorApiRef, - HeaderTabs, -} from '@backstage/core'; -import ComponentContextMenu from '../ComponentContextMenu/ComponentContextMenu'; -import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDialog'; - -import { SentryIssuesWidget } from '@backstage/plugin-sentry'; -import { Grid } from '@material-ui/core'; -import { catalogApiRef } from '../..'; -import { entityToComponent } from '../../data/utils'; -import { Component } from '../../data/component'; - -const REDIRECT_DELAY = 1000; - -type ComponentPageProps = { - match: { - params: { - name: string; - }; - }; - history: { - push: (url: string) => void; - }; -}; - -const ComponentPage: FC = ({ match, history }) => { - const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); - const [removingPending, setRemovingPending] = useState(false); - const showRemovalDialog = () => setConfirmationDialogOpen(true); - const hideRemovalDialog = () => setConfirmationDialogOpen(false); - const componentName = match.params.name; - const errorApi = useApi(errorApiRef); - - const catalogApi = useApi(catalogApiRef); - const { value: component, error, loading } = useAsync(async () => { - const entity = await catalogApi.getEntityByName(match.params.name); - const location = await catalogApi.getLocationByEntity(entity); - return { ...entityToComponent(entity), location }; - }); - - useEffect(() => { - if (error) { - errorApi.post(new Error('Component not found!')); - setTimeout(() => { - history.push('/'); - }, REDIRECT_DELAY); - } - }, [error, errorApi, history]); - - if (componentName === '') { - history.push('/catalog'); - return null; - } - - const removeComponent = async () => { - setConfirmationDialogOpen(false); - setRemovingPending(true); - // await componentFactory.removeComponentByName(componentName); - - await catalogApi; - history.push('/'); - }; - - // TODO - Replace with proper tabs implementation - const tabs = [ - { - id: 'overview', - label: 'Overview', - }, - { - id: 'ci', - label: 'CI/CD', - }, - { - id: 'tests', - label: 'Tests', - }, - { - id: 'api', - label: 'API', - }, - { - id: 'monitoring', - label: 'Monitoring', - }, - { - id: 'quality', - label: 'Quality', - }, - ]; - - return ( - // TODO: Switch theme and type props based on component type (website, library, ...) - -
- -
- - - {confirmationDialogOpen && component && ( - - )} - - - - - - - - - - -
- ); -}; -export default ComponentPage; diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx similarity index 72% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 5b81fc6cc1..68306944a3 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import ComponentContextMenu from './ComponentContextMenu'; -import { render } from '@testing-library/react'; + +import { render, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; +import { EntityContextMenu } from './EntityContextMenu'; describe('ComponentContextMenu', () => { - it('should call onUnregisterComponent on button click', async () => { + it('should call onUnregisterEntity on button click', async () => { await act(async () => { const mockCallback = jest.fn(); const menu = render( - , + , ); const button = await menu.findByTestId('menu-button'); - button.click(); - const unregister = await menu.findByText('Unregister component'); + fireEvent.click(button); + const unregister = await menu.findByText('Unregister entity'); expect(unregister).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx similarity index 87% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 66c234e55e..fd52b97cb1 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { IconButton, ListItemIcon, @@ -21,11 +22,11 @@ import { Popover, Typography, } from '@material-ui/core'; +import { makeStyles } from '@material-ui/core/styles'; import Cancel from '@material-ui/icons/Cancel'; import MoreVert from '@material-ui/icons/MoreVert'; import SwapHoriz from '@material-ui/icons/SwapHoriz'; import React, { FC, useState } from 'react'; -import { makeStyles } from '@material-ui/core/styles'; // TODO(freben): It should probably instead be the case that Header sets the theme text color to white inside itself unconditionally instead const useStyles = makeStyles({ @@ -34,13 +35,11 @@ const useStyles = makeStyles({ }, }); -type ComponentContextMenuProps = { - onUnregisterComponent: () => void; +type Props = { + onUnregisterEntity: () => void; }; -const ComponentContextMenu: FC = ({ - onUnregisterComponent, -}) => { +export const EntityContextMenu: FC = ({ onUnregisterEntity }) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -53,7 +52,7 @@ const ComponentContextMenu: FC = ({ }; return ( -
+ <> = ({ { onClose(); - onUnregisterComponent(); + onUnregisterEntity(); }} > - Unregister component + Unregister entity @@ -91,8 +90,6 @@ const ComponentContextMenu: FC = ({ -
+ ); }; - -export default ComponentContextMenu; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx similarity index 53% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx rename to plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx index 5b6e7dfe07..e0027431ff 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx @@ -13,28 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import React from 'react'; -import ComponentMetadataCard from './ComponentMetadataCard'; -import { Component } from '../../data/component'; -import { render } from '@testing-library/react'; -describe('ComponentMetadataCard component', () => { - it('should display component name if provided', async () => { - const testComponent: Component = { - name: 'test', +import { Entity } from '@backstage/catalog-model'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { EntityMetadataCard } from './EntityMetadataCard'; + +describe('EntityMetadataCard component', () => { + it('should display entity name if provided', async () => { + const testEntity: Entity = { + apiVersion: 'backstage.io/v1beta1', kind: 'Component', metadata: { name: 'test' }, - description: 'Placeholder', }; - const rendered = await render( - , - ); + const rendered = await render(); expect(await rendered.findByText('test')).toBeInTheDocument(); }); - it('should display loader when loading is set to true', async () => { - const rendered = await render( - , - ); - expect(await rendered.findByRole('progressbar')).toBeInTheDocument(); - }); }); diff --git a/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx new file mode 100644 index 0000000000..d7a3bc81f6 --- /dev/null +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx @@ -0,0 +1,29 @@ +/* + * 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 { InfoCard, StructuredMetadataTable } from '@backstage/core'; +import React, { FC } from 'react'; + +type Props = { + entity: Entity; +}; + +export const EntityMetadataCard: FC = ({ entity }) => ( + + + +); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx similarity index 77% rename from plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx rename to plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index 252f2d4e3f..22a47c192e 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -13,18 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import ComponentPage from './ComponentPage'; + +import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; +import { wrapInTestApp } from '@backstage/test-utils'; import { render, wait } from '@testing-library/react'; import * as React from 'react'; -import { wrapInTestApp } from '@backstage/test-utils'; -import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core'; -import { catalogApiRef, CatalogApi } from '../../api/types'; +import { CatalogApi, catalogApiRef } from '../../api/types'; +import { EntityPage } from './EntityPage'; -const getTestProps = (componentName: string) => { +const getTestProps = (name: string) => { return { match: { params: { - name: componentName, + optionalNamespaceAndName: name, + kind: 'Component', }, }, history: { @@ -35,8 +37,8 @@ const getTestProps = (componentName: string) => { const errorApi = { post: () => {} }; -describe('ComponentPage', () => { - it('should redirect to component table page when name is not provided', async () => { +describe('EntityPage', () => { + it('should redirect to catalog page when name is not provided', async () => { const props = getTestProps(''); render( wrapInTestApp( @@ -47,11 +49,11 @@ describe('ComponentPage', () => { catalogApiRef, ({ async getEntityByName() {}, - } as unknown) as CatalogApi, + } as Partial) as CatalogApi, ], ])} > - + , ), ); diff --git a/plugins/catalog/src/components/EntityPage/EntityPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx new file mode 100644 index 0000000000..96132819e8 --- /dev/null +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -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 { Entity } from '@backstage/catalog-model'; +import { + Content, + errorApiRef, + Header, + HeaderTabs, + Page, + pageTheme, + Progress, + useApi, +} from '@backstage/core'; +import { SentryIssuesWidget } from '@backstage/plugin-sentry'; +import { Grid } from '@material-ui/core'; +import { Alert } from '@material-ui/lab'; +import React, { FC, useEffect, useState } from 'react'; +import { useAsync } from 'react-use'; +import { catalogApiRef } from '../..'; +import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu'; +import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard'; +import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog'; + +const REDIRECT_DELAY = 1000; + +type Props = { + match: { + params: { + optionalNamespaceAndName: string; + kind: string; + }; + }; + history: { + push: (url: string) => void; + }; +}; + +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 EntityPage: FC = ({ match, history }) => { + const { optionalNamespaceAndName, kind } = match.params; + 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(() => { + history.push('/'); + }, REDIRECT_DELAY); + } + }, [errorApi, history, error, loading, entity]); + + if (!name) { + history.push('/catalog'); + return null; + } + + const cleanUpAfterRemoval = async () => { + setConfirmationDialogOpen(false); + history.push('/'); + }; + + const showRemovalDialog = () => setConfirmationDialogOpen(true); + + // TODO - Replace with proper tabs implementation + const tabs = [ + { + id: 'overview', + label: 'Overview', + }, + { + id: 'ci', + label: 'CI/CD', + }, + { + id: 'tests', + label: 'Tests', + }, + { + id: 'api', + label: 'API', + }, + { + id: 'monitoring', + label: 'Monitoring', + }, + { + id: 'quality', + label: 'Quality', + }, + ]; + + const { headerTitle, headerType } = headerProps( + kind, + namespace, + name, + entity, + ); + + return ( + // TODO: Switch theme and type props based on component type (website, library, ...) + +
+ {entity && } +
+ + {loading && } + + {error && ( + + {error.toString()} + + )} + + {entity && ( + <> + + + + + + + + + + + + + + setConfirmationDialogOpen(false)} + /> + + )} +
+ ); +}; diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx similarity index 72% rename from plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx rename to plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index 1318edcca0..e4eec3c21f 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -15,7 +15,7 @@ */ import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; -import { Progress, useApi } from '@backstage/core'; +import { Progress, useApi, alertApiRef } from '@backstage/core'; import { Button, Dialog, @@ -32,39 +32,51 @@ import React, { FC } from 'react'; import { useAsync } from 'react-use'; import { AsyncState } from 'react-use/lib/useAsync'; import { catalogApiRef } from '../../api/types'; -import { Component } from '../../data/component'; -type ComponentRemovalDialogProps = { +type Props = { + open: boolean; onConfirm: () => any; - onCancel: () => any; onClose: () => any; - component: Component; + entity: Entity; }; -function useColocatedEntities(component: Component): AsyncState { +function useColocatedEntities(entity: Entity): AsyncState { const catalogApi = useApi(catalogApiRef); return useAsync(async () => { - const myLocation = component.metadata.annotations?.[LOCATION_ANNOTATION]; + const myLocation = entity.metadata.annotations?.[LOCATION_ANNOTATION]; return myLocation ? await catalogApi.getEntities({ [LOCATION_ANNOTATION]: myLocation }) : []; - }, [catalogApi, component]); + }, [catalogApi, entity]); } -const ComponentRemovalDialog: FC = ({ +export const UnregisterEntityDialog: FC = ({ + open, onConfirm, - onCancel, onClose, - component, + entity, }) => { - const { value: entities, loading, error } = useColocatedEntities(component); + const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const removeEntity = async () => { + const uid = entity.metadata.uid; + try { + await catalogApi.removeEntityByUid(uid!); + } catch (err) { + alertApi.post({ message: err.message }); + } + + onConfirm(); + }; return ( - + - Are you sure you want to unregister this component? + Are you sure you want to unregister this entity? {loading ? : null} @@ -91,21 +103,23 @@ const ComponentRemovalDialog: FC = ({
  • - {entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]} + {entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
- To undo, just re-register the component in Backstage. + To undo, just re-register the entity in Backstage. ) : null}
- +
); }; - -export default ComponentRemovalDialog; diff --git a/plugins/catalog/src/data/filters.ts b/plugins/catalog/src/data/filters.ts index 1fcdfa27a2..71efa923c6 100644 --- a/plugins/catalog/src/data/filters.ts +++ b/plugins/catalog/src/data/filters.ts @@ -13,30 +13,35 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + +import { Entity } from '@backstage/catalog-model'; +import SettingsIcon from '@material-ui/icons/Settings'; +import StarIcon from '@material-ui/icons/Star'; +import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount'; import { CatalogFilterGroup, CatalogFilterItem, } from '../components/CatalogFilter/CatalogFilter'; -import SettingsIcon from '@material-ui/icons/Settings'; -import StarIcon from '@material-ui/icons/Star'; import { StarredCount } from '../components/CatalogFilter/StarredCount'; -import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount'; -import { FilterGroupItem } from '../types'; -import { CatalogApi } from '../..'; -import { Entity } from '@backstage/catalog-model'; + +export enum EntityFilterType { + ALL = 'ALL', + STARRED = 'STARRED', + OWNED = 'OWNED', +} export const filterGroups: CatalogFilterGroup[] = [ { name: 'Personal', items: [ { - id: FilterGroupItem.OWNED, + id: EntityFilterType.OWNED, label: 'Owned', count: 0, icon: SettingsIcon, }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Starred', count: StarredCount, icon: StarIcon, @@ -48,7 +53,7 @@ export const filterGroups: CatalogFilterGroup[] = [ name: 'Company', items: [ { - id: FilterGroupItem.ALL, + id: EntityFilterType.ALL, label: 'All Services', count: AllServicesCount, }, @@ -56,26 +61,16 @@ export const filterGroups: CatalogFilterGroup[] = [ }, ]; -type ResolverFunction = ({ - catalogApi, - starredEntities, -}: { - catalogApi: CatalogApi; - starredEntities: Set; -}) => Promise; +type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean; -export const dataResolvers: Record = { - [FilterGroupItem.OWNED]: async () => [], - [FilterGroupItem.ALL]: async ({ catalogApi }) => { - return catalogApi.getEntities(); - }, - [FilterGroupItem.STARRED]: async ({ catalogApi, starredEntities }) => { - const allEntities = await catalogApi.getEntities(); +type EntityFilterOptions = { + isStarred: boolean; +}; - return allEntities.filter(entity => - starredEntities.has(entity.metadata.name), - ); - }, +export const entityFilters: Record = { + [EntityFilterType.OWNED]: () => false, + [EntityFilterType.ALL]: () => true, + [EntityFilterType.STARRED]: (_, { isStarred }) => isStarred, }; export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0]; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/data/utils.ts index b731268c41..df14875092 100644 --- a/plugins/catalog/src/data/utils.ts +++ b/plugins/catalog/src/data/utils.ts @@ -13,22 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { - Entity, + EntityMeta, LocationSpec, LOCATION_ANNOTATION, - EntityMeta, } from '@backstage/catalog-model'; -import { Component } from './component'; - -export function entityToComponent(envelope: Entity): Component { - return { - name: envelope.metadata.name, - kind: envelope.kind, - metadata: envelope.metadata, - description: envelope.metadata.annotations?.description ?? 'placeholder', - }; -} export function findLocationForEntityMeta( meta: EntityMeta, diff --git a/plugins/catalog/src/hooks/useStarredEntites.ts b/plugins/catalog/src/hooks/useStarredEntites.ts index 631991e9b0..7cbbbb7ce6 100644 --- a/plugins/catalog/src/hooks/useStarredEntites.ts +++ b/plugins/catalog/src/hooks/useStarredEntites.ts @@ -13,17 +13,25 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useState, useEffect } from 'react'; -import { useApi, storageApiRef } from '@backstage/core'; + +import { Entity } from '@backstage/catalog-model'; +import { storageApiRef, useApi } from '@backstage/core'; +import { useCallback, useEffect, useState } from 'react'; import { useObservable } from 'react-use'; +const buildEntityKey = (component: Entity) => + `entity:${component.kind}:${component.metadata.namespace ?? 'default'}:${ + component.metadata.name + }`; + export const useStarredEntities = () => { const storageApi = useApi(storageApiRef); const settingsStore = storageApi.forBucket('settings'); - const rawStarredItems = settingsStore.get('starredEntities') ?? []; + const rawStarredEntityKeys = + settingsStore.get('starredEntities') ?? []; const [starredEntities, setStarredEntities] = useState( - new Set(rawStarredItems), + new Set(rawStarredEntityKeys), ); const observedItems = useObservable( @@ -37,7 +45,31 @@ export const useStarredEntities = () => { } }, [observedItems?.newValue]); + const toggleStarredEntity = useCallback( + (entity: Entity) => { + const entityKey = buildEntityKey(entity); + if (starredEntities.has(entityKey)) { + starredEntities.delete(entityKey); + } else { + starredEntities.add(entityKey); + } + + settingsStore.set('starredEntities', Array.from(starredEntities)); + }, + [starredEntities, settingsStore], + ); + + const isStarredEntity = useCallback( + (entity: Entity) => { + const entityKey = buildEntityKey(entity); + return starredEntities.has(entityKey); + }, + [starredEntities], + ); + return { starredEntities, + toggleStarredEntity, + isStarredEntity, }; }; diff --git a/plugins/catalog/src/hooks/useStarredEntities.test.tsx b/plugins/catalog/src/hooks/useStarredEntities.test.tsx index 49890b7238..6c98690553 100644 --- a/plugins/catalog/src/hooks/useStarredEntities.test.tsx +++ b/plugins/catalog/src/hooks/useStarredEntities.test.tsx @@ -13,8 +13,9 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import React from 'react'; -import { renderHook } from '@testing-library/react-hooks'; +import { renderHook, act } from '@testing-library/react-hooks'; import { useStarredEntities } from './useStarredEntites'; import { ApiProvider, @@ -24,10 +25,28 @@ import { StorageApi, } from '@backstage/core'; import { MockErrorApi } from '@backstage/test-utils'; +import { Entity } from '@backstage/catalog-model'; describe('useStarredEntities', () => { let mockStorage: StorageApi | undefined; + const mockEntity: Entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + name: 'mock', + }, + }; + + const secondMockEntity: Entity = { + apiVersion: '1', + kind: 'Component', + metadata: { + namespace: 'test', + name: 'mock2', + }, + }; + const wrapper: React.FC<{}> = ({ children }) => { return ( @@ -58,23 +77,45 @@ describe('useStarredEntities', () => { } }); it('should listen to changes when the storage is set elsewhere', async () => { - const store = mockStorage?.forBucket('settings'); - const { result, waitForNextUpdate } = renderHook( () => useStarredEntities(), { wrapper }, ); expect(result.current.starredEntities.size).toBe(0); - expect(result.current.starredEntities.has('something')).toBeFalsy(); + expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); // Make this happen after awaiting for the next update so we can // catch when the hook re-renders with the latest data - setTimeout(() => store?.set('starredEntities', ['something']), 1); + setTimeout(() => result.current.toggleStarredEntity(mockEntity), 1); await waitForNextUpdate(); expect(result.current.starredEntities.size).toBe(1); - expect(result.current.starredEntities.has('something')).toBeTruthy(); + expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); + }); + + it('should write new entries to the local store when adding a togglging entity', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + act(() => { + result.current.toggleStarredEntity(mockEntity); + }); + + expect(result.current.isStarredEntity(mockEntity)).toBeTruthy(); + expect(result.current.isStarredEntity(secondMockEntity)).toBeFalsy(); + }); + + it('should remove an existing entity when toggling entries', async () => { + const { result } = renderHook(() => useStarredEntities(), { wrapper }); + + act(() => { + result.current.toggleStarredEntity(mockEntity); + result.current.toggleStarredEntity(secondMockEntity); + result.current.toggleStarredEntity(mockEntity); + }); + + expect(result.current.isStarredEntity(mockEntity)).toBeFalsy(); + expect(result.current.isStarredEntity(secondMockEntity)).toBeTruthy(); }); }); diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 2b986b59ea..701394df34 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -17,5 +17,4 @@ export { plugin } from './plugin'; export * from './api/CatalogClient'; export * from './api/types'; -export * from './types'; export * from './routes'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 1eee5efe07..8f4ac80f48 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -15,14 +15,14 @@ */ import { createPlugin } from '@backstage/core'; -import CatalogPage from './components/CatalogPage'; -import ComponentPage from './components/ComponentPage/ComponentPage'; -import { rootRoute, entityRoute } from './routes'; +import { CatalogPage } from './components/CatalogPage/CatalogPage'; +import { EntityPage } from './components/EntityPage/EntityPage'; +import { entityRoute, rootRoute } from './routes'; export const plugin = createPlugin({ id: 'catalog', register({ router }) { router.addRoute(rootRoute, CatalogPage); - router.addRoute(entityRoute, ComponentPage); + router.addRoute(entityRoute, EntityPage); }, }); diff --git a/plugins/catalog/src/routes.ts b/plugins/catalog/src/routes.ts index 6498d412b8..69c4e651b4 100644 --- a/plugins/catalog/src/routes.ts +++ b/plugins/catalog/src/routes.ts @@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({ }); export const entityRoute = createRouteRef({ icon: NoIcon, - path: '/catalog/:name/', + path: '/catalog/:kind/:optionalNamespaceAndName/', title: 'Entity', }); diff --git a/plugins/catalog/src/types.ts b/plugins/catalog/src/types.ts deleted file mode 100644 index 42f156ecb7..0000000000 --- a/plugins/catalog/src/types.ts +++ /dev/null @@ -1,165 +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. - */ - -export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope { - spec: { - type: string; - }; -} - -export type ComponentDescriptor = ComponentDescriptorV1beta1; - -/** - * Metadata fields common to all versions/kinds of entity. - * - * @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta - */ -export type EntityMeta = { - /** - * A globally unique ID for the entity. - * - * This field can not be set by the user at creation time, and the server - * will reject an attempt to do so. The field will be populated in read - * operations. The field can (optionally) be specified when performing - * update or delete operations, but the server is free to reject requests - * that do so in such a way that it breaks semantics. - */ - uid?: string; - - /** - * An opaque string that changes for each update operation to any part of - * the entity, including metadata. - * - * This field can not be set by the user at creation time, and the server - * will reject an attempt to do so. The field will be populated in read - * operations. The field can (optionally) be specified when performing - * update or delete operations, and the server will then reject the - * operation if it does not match the current stored value. - */ - etag?: string; - - /** - * A positive nonzero number that indicates the current generation of data - * for this entity; the value is incremented each time the spec changes. - * - * This field can not be set by the user at creation time, and the server - * will reject an attempt to do so. The field will be populated in read - * operations. - */ - generation?: number; - - /** - * The name of the entity. - * - * Must be uniqe within the catalog at any given point in time, for any - * given namespace, for any given kind. - */ - name: string; - - /** - * The short description of the entity. - * - * A a human readable string. - */ - description: string; - - /** - * The namespace that the entity belongs to. - */ - namespace?: string; - - /** - * Key/value pairs of identifying information attached to the entity. - */ - labels?: Record; - - /** - * Key/value pairs of non-identifying auxiliary information attached to the - * entity. - */ - annotations?: Record; -}; - -/** - * The format envelope that's common to all versions/kinds. - * - * @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/ - */ -export type DescriptorEnvelope = { - /** - * The version of specification format for this particular entity that - * this is written against. - */ - apiVersion: string; - - /** - * The high level entity type being described. - */ - kind: string; - - /** - * Optional metadata related to the entity. - */ - metadata: EntityMeta; - - /** - * The specification data describing the entity itself. - */ - spec?: object; -}; - -/** - * Parses and validates descriptors. - * - * The output must be validated and well formed. - */ -export type DescriptorParser = { - /** - * Parses and validates a single raw descriptor. - * - * @param descriptor A raw descriptor object - * @returns A structure describing the parsed and validated descriptor - * @throws An Error if the descriptor was malformed - */ - parse(descriptor: object): Promise; -}; - -/** - * Parses and validates a single envelope into its materialized kind. - * - * These parsers may assume that the envelope is already validated and well - * formed. - */ -export type KindParser = { - /** - * Try to parse an envelope into a materialized kind. - * - * @param envelope A valid descriptor envelope - * @returns A materialized type, or undefined if the given version/kind is - * not meant to be handled by this parser - * @throws An Error if the type was handled and found to not be properly - * formatted - */ - tryParse( - envelope: DescriptorEnvelope, - ): Promise; -}; - -export enum FilterGroupItem { - ALL = 'ALL', - STARRED = 'STARRED', - OWNED = 'OWNED', -} diff --git a/plugins/circleci/src/components/Layout/Layout.tsx b/plugins/circleci/src/components/Layout/Layout.tsx index a614d388f0..09479e6ef0 100644 --- a/plugins/circleci/src/components/Layout/Layout.tsx +++ b/plugins/circleci/src/components/Layout/Layout.tsx @@ -15,20 +15,12 @@ */ import React from 'react'; import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core'; -import { Box } from '@material-ui/core'; export const Layout: React.FC = ({ children }) => { return ( -
- Circle CI - - } - > - +
+
{children} diff --git a/plugins/explore/src/components/ExplorePluginPage.tsx b/plugins/explore/src/components/ExplorePluginPage.tsx index 12c0850032..c1d36f2544 100644 --- a/plugins/explore/src/components/ExplorePluginPage.tsx +++ b/plugins/explore/src/components/ExplorePluginPage.tsx @@ -80,6 +80,14 @@ const toolsCards = [ 'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432', tags: ['graphql', 'dev'], }, + { + title: 'GitOps Clusters', + description: + 'Create GitOps-managed clusters with Backstage. Currently supports EKS flavors and profiles like Machine Learning Ops (MLOps)', + url: '/gitops-clusters', + image: 'https://miro.medium.com/max/801/1*R28u8gj-hVdDFISoYqPhrQ.png', + tags: ['gitops', 'dev'], + }, ]; const ExplorePluginPage: FC<{}> = () => { diff --git a/plugins/gitops-profiles/.eslintrc.js b/plugins/gitops-profiles/.eslintrc.js new file mode 100644 index 0000000000..13573efa9c --- /dev/null +++ b/plugins/gitops-profiles/.eslintrc.js @@ -0,0 +1,3 @@ +module.exports = { + extends: [require.resolve('@backstage/cli/config/eslint')], +}; diff --git a/plugins/gitops-profiles/README.md b/plugins/gitops-profiles/README.md new file mode 100644 index 0000000000..d083c97b84 --- /dev/null +++ b/plugins/gitops-profiles/README.md @@ -0,0 +1,26 @@ +# gitops-profiles + +Welcome to the gitops-profiles plugin! +This plugin is for creating GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions. + +_This plugin was created through the Backstage CLI_ + +## Plugin Development + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gitops-clusters](http://localhost:3000/gitops-profiles). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory. + +## Use GitOps-API backend with Backstage + +The backend of this plugin is written in Golang and its source code is available [here](https://github.com/chanwit/gitops-api) as a separate GitHub repository. +The binary of this plugin is available as a ready-to-use Docker image, [https://hub.docker.com/chanwit/gitops-api](https://hub.docker.com/chanwit/gitops-api). +To start using GitOps with Backstage, you have to start the backend using the following command: + +```bash +$ docker run -d --init -p 3008:8080 chanwit/gitops-api +``` + +Please note that this plugin requires the backend to run on port 3008. diff --git a/plugins/gitops-profiles/dev/index.tsx b/plugins/gitops-profiles/dev/index.tsx new file mode 100644 index 0000000000..812a5585d4 --- /dev/null +++ b/plugins/gitops-profiles/dev/index.tsx @@ -0,0 +1,20 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createDevApp } from '@backstage/dev-utils'; +import { plugin } from '../src/plugin'; + +createDevApp().registerPlugin(plugin).render(); diff --git a/plugins/gitops-profiles/package.json b/plugins/gitops-profiles/package.json new file mode 100644 index 0000000000..703d832666 --- /dev/null +++ b/plugins/gitops-profiles/package.json @@ -0,0 +1,49 @@ +{ + "name": "@backstage/plugin-gitops-profiles", + "version": "0.1.1-alpha.7", + "main": "dist/index.esm.js", + "main:src": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "private": true, + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "scripts": { + "build": "backstage-cli plugin:build", + "start": "backstage-cli plugin:serve", + "lint": "backstage-cli lint", + "test": "backstage-cli test", + "diff": "backstage-cli plugin:diff", + "prepack": "backstage-cli prepack", + "postpack": "backstage-cli postpack", + "clean": "backstage-cli clean" + }, + "dependencies": { + "@backstage/core": "^0.1.1-alpha.7", + "@backstage/theme": "^0.1.1-alpha.7", + "@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-use": "^14.2.0", + "react-router-dom": "^5.2.0" + }, + "devDependencies": { + "@backstage/cli": "^0.1.1-alpha.7", + "@backstage/dev-utils": "^0.1.1-alpha.7", + "@testing-library/jest-dom": "^5.7.0", + "@testing-library/react": "^9.3.2", + "@testing-library/user-event": "^10.2.4", + "@types/jest": "^25.2.2", + "@types/node": "^12.0.0", + "@types/testing-library__jest-dom": "^5.0.4", + "jest-fetch-mock": "^3.0.3" + }, + "files": [ + "dist/**/*.{js,d.ts}" + ] +} diff --git a/plugins/gitops-profiles/src/api.ts b/plugins/gitops-profiles/src/api.ts new file mode 100644 index 0000000000..ad95b1cd52 --- /dev/null +++ b/plugins/gitops-profiles/src/api.ts @@ -0,0 +1,170 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createApiRef } from '@backstage/core-api'; + +export interface CloneFromTemplateRequest { + templateRepository: string; + secrets: { + awsAccessKeyId: string; + awsSecretAccessKey: string; + }; + targetOrg: string; + targetRepo: string; + gitHubUser: string; + gitHubToken: string; +} + +export interface ApplyProfileRequest { + targetOrg: string; + targetRepo: string; + gitHubUser: string; + gitHubToken: string; + profiles: string[]; +} + +export interface ChangeClusterStateRequest { + targetOrg: string; + targetRepo: string; + gitHubUser: string; + gitHubToken: string; + clusterState: 'present' | 'absent'; // /api/cluster/state +} + +export interface PollLogRequest { + targetOrg: string; + targetRepo: string; + gitHubUser: string; + gitHubToken: string; +} + +export interface Status { + status: string; // queued, in_progress, or completed + message: string; + conclusion: string; // success, failure, neutral, cancelled, skipped, timed_out, or action_required +} + +export interface StatusResponse { + result: Status[]; + link: string; + status: string; +} + +export interface ClusterStatus { + name: string; + link: string; + status: string; + conclusion: string; + runStatus: Status[]; +} + +export interface ListClusterStatusesResponse { + result: ClusterStatus[]; +} + +export interface ListClusterRequest { + gitHubUser: string; + gitHubToken: string; +} + +export class FetchError extends Error { + get name(): string { + return this.constructor.name; + } + + static async forResponse(resp: Response): Promise { + return new FetchError( + `Request failed with status code ${ + resp.status + }.\nReason: ${await resp.text()}`, + ); + } +} + +export type GitOpsApi = { + url: string; + fetchLog(req: PollLogRequest): Promise; + changeClusterState(req: ChangeClusterStateRequest): Promise; + cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise; + applyProfiles(req: ApplyProfileRequest): Promise; + listClusters(req: ListClusterRequest): Promise; +}; + +export const gitOpsApiRef = createApiRef({ + id: 'plugin.gitops.service', + description: 'Used by the GitOps profiles plugin to make requests', +}); + +export class GitOpsRestApi implements GitOpsApi { + constructor(public url: string = '') {} + + private async fetch(path: string, init?: RequestInit): Promise { + const resp = await fetch(`${this.url}${path}`, init); + if (!resp.ok) throw await FetchError.forResponse(resp); + return await resp.json(); + } + + async fetchLog(req: PollLogRequest): Promise { + return await this.fetch(`/api/cluster/run-status`, { + method: 'post', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(req), + }); + } + + async changeClusterState(req: ChangeClusterStateRequest): Promise { + return await this.fetch('/api/cluster/state', { + method: 'post', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(req), + }); + } + + async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise { + return await this.fetch('/api/cluster/clone-from-template', { + method: 'post', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(req), + }); + } + + async applyProfiles(req: ApplyProfileRequest): Promise { + return await this.fetch('/api/cluster/profiles', { + method: 'post', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(req), + }); + } + + async listClusters( + req: ListClusterRequest, + ): Promise { + return await this.fetch('/api/clusters', { + method: 'post', + headers: new Headers({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(req), + }); + } +} diff --git a/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx new file mode 100644 index 0000000000..0bdad1c8ab --- /dev/null +++ b/plugins/gitops-profiles/src/components/ClusterList/ClusterList.tsx @@ -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 React, { FC } from 'react'; +import { + Content, + ContentHeader, + Header, + SupportButton, + Page, + pageTheme, + Progress, + HeaderLabel, + useApi, +} from '@backstage/core'; + +import ClusterTable from '../ClusterTable/ClusterTable'; +import { Button, Typography } from '@material-ui/core'; +import { useAsync, useLocalStorage } from 'react-use'; +import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api'; + +const ClusterList: FC<{}> = () => { + const [loginInfo] = useLocalStorage<{ + token: string; + username: string; + name: string; + }>('githubLoginDetails'); + + const api = useApi(gitOpsApiRef); + + const { loading, error, value } = useAsync( + () => { + return api.listClusters({ + gitHubToken: loginInfo.token, + gitHubUser: loginInfo.username, + }); + }, + ); + let content: JSX.Element; + if (loading) { + content = ( + + + + ); + } else if (error) { + content = ( + + + Failed to load cluster, {String(error)} + + + ); + } else { + content = ( + + + + All clusters + + + + ); + } + + return ( + +
+ +
+ {content} +
+ ); +}; + +export default ClusterList; diff --git a/plugins/catalog/src/components/CatalogPage/index.ts b/plugins/gitops-profiles/src/components/ClusterList/index.ts similarity index 93% rename from plugins/catalog/src/components/CatalogPage/index.ts rename to plugins/gitops-profiles/src/components/ClusterList/index.ts index 61182e316f..e4260e5374 100644 --- a/plugins/catalog/src/components/CatalogPage/index.ts +++ b/plugins/gitops-profiles/src/components/ClusterList/index.ts @@ -14,4 +14,4 @@ * limitations under the License. */ -export { default } from './CatalogPage'; +export { default } from './ClusterList'; diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx new file mode 100644 index 0000000000..29559e4fec --- /dev/null +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -0,0 +1,103 @@ +/* + * 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, { FC, useEffect, useState } from 'react'; +import { + Content, + Header, + Page, + pageTheme, + Table, + Progress, + HeaderLabel, + useApi, +} from '@backstage/core'; + +import { Link } from '@material-ui/core'; +import { useParams } from 'react-router-dom'; +import { useLocalStorage } from 'react-use'; +import { gitOpsApiRef, Status } from '../../api'; +import { transformRunStatus } from '../ProfileCatalog'; + +const ClusterPage: FC<{}> = () => { + const params = useParams<{ owner: string; repo: string }>(); + + const [loginInfo] = useLocalStorage<{ + token: string; + username: string; + name: string; + }>('githubLoginDetails'); + + const [pollingLog, setPollingLog] = useState(true); + const [runStatus, setRunStatus] = useState([]); + const [runLink, setRunLink] = useState(''); + const [showProgress, setShowProgress] = useState(true); + + const api = useApi(gitOpsApiRef); + + const columns = [ + { field: 'status', title: 'Status' }, + { field: 'message', title: 'Message' }, + ]; + + useEffect(() => { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: loginInfo.token, + gitHubUser: loginInfo.username, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } + return () => {}; + }, [pollingLog, api, loginInfo, params]); + + return ( + +
+ +
+ +