Merge branch 'master' of github.com:spotify/backstage into blam/msw

* 'master' of github.com:spotify/backstage: (76 commits)
  Fix incorrect backend path in docs (#1258)
  packages/core-api: temporary solution for giving access to config when creating APIs
  chore(catalog): simplify the filter types a little
  fix(catalog-backend): update the mock-data script to point to new example entities
  renamed example_components to example-components and deleted old exampled
  feat(catalog): add back ability for OR/IN type searches
  Add sample plugins to sidebar (#1243)
  chore(catalog): rename all pages and components to use Entity nomenclature
  fix(catalog): moar clean up
  Updated examples
  fix(catalog): add types and clean up code
  Added owner and lifecycle to catalog table, slightly updated examples
  chore(catalog): the component type is gone
  yarn.lock again...
  fix(catalog): merge errors
  review fixes. i thought about another force update for a moment :D
  move components to separate files
  Use URLSearchParams
  fix(catalog): moar clean up
  fix(catalog): make code intention clear by renaming
  ...
This commit is contained in:
blam
2020-06-11 23:12:27 +02:00
125 changed files with 3640 additions and 1274 deletions
+1
View File
@@ -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",
+1 -1
View File
@@ -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({
+55 -46
View File
@@ -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();
};
+10 -2
View File
@@ -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 }) => (
<SidebarItem icon={CreateComponentIcon} to="/create" text="Create..." />
{/* End global nav */}
<SidebarDivider />
<SidebarItem icon={MapIcon} to="/tech-radar" text="Tech Radar" />
<SidebarItem icon={RuleIcon} to="/lighthouse" text="Lighthouse" />
<SidebarItem icon={BuildIcon} to="/circleci" text="CircleCI" />
<SidebarSpace />
<SidebarDivider />
<SidebarThemeToggle />
<SidebarUserBadge />
<SidebarUserSettings />
<SidebarPinButton />
</Sidebar>
{children}
</SidebarPage>
+1
View File
@@ -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';
+5
View File
@@ -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",
+1
View File
@@ -17,3 +17,4 @@
export * from './errors';
export * from './logging';
export * from './middleware';
export * from './service';
@@ -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<Server> {
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,
};
}
}
@@ -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();
}
@@ -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';
@@ -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<Server>;
};
+4 -9
View File
@@ -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.
-5
View File
@@ -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",
+19 -38
View File
@@ -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);
});
}
+11 -2
View File
@@ -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<SessionState>;
};
/**
* 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<OAuthApi>({
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
id: 'core.auth.github',
description: 'Provides authentication towards Github APIs',
});
@@ -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<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
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<string> {
@@ -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<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
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;
}
+18 -4
View File
@@ -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;
+5 -2
View File
@@ -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<AppConfig[]>;
// 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.
@@ -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);
});
});
@@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
async getCurrentSession() {
@@ -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>(SessionState.SignedOut);
setIsSignedId(isSignedIn: boolean) {
if (this.signedIn !== isSignedIn) {
this.signedIn = isSignedIn;
this.observable.next(
this.signedIn ? SessionState.SignedIn : SessionState.SignedOut,
);
}
}
}
@@ -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);
});
});
@@ -64,7 +64,7 @@ export class StaticAuthSessionManager<T> implements SessionManager<T> {
}
async removeSession() {
this.currentSession = undefined;
await this.connector.removeSession();
window.location.reload(); // TODO(Rugvip): make this work without reload?
}
}
@@ -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('<Tab />', () => {
it('renders without exploding', () => {
const rendered = render(wrapInTestApp(<StyledTab label="test" />));
expect(rendered.getByText('test')).toBeInTheDocument();
});
});
+62
View File
@@ -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<BackstageTheme, StyledTabProps>(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 <Tab className={classes.root} disableRipple {...rest} />;
};
@@ -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<BackstageTheme>(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<StyledTabsProps> = props => {
const classes = useStyles(props);
return (
<Tabs
classes={classes}
{...props}
TabIndicatorProps={{ children: <span /> }}
/>
);
};
@@ -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<BackstageTheme, StyledIconProps>(() => ({
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 (
<IconButton
onClick={onClick}
className={classes.root}
size="small"
disableRipple
disableFocusRipple
aria-label={ariaLabel}
>
{props.children}
</IconButton>
);
};
@@ -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<ComponentMetadataCardProps> = ({
loading,
component,
}) => {
if (loading) {
return (
<InfoCard title="Metadata">
<Progress />
</InfoCard>
);
}
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<TabPanelProps> = props => {
const { children, value, index, ...other } = props;
return (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={component} />
</InfoCard>
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`scrollable-auto-tab-${index}`}
{...other}
>
{value === index && <Box p={3}>{children}</Box>}
</div>
);
};
export default ComponentMetadataCard;
@@ -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 = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Expandable = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(31)].map((_, index) => ({
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const Icons = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
export const IconsAndLabels = () => (
<div style={containerStyle}>
<Tabs
tabs={[...Array(4)].map((_, index) => ({
icon: <AccessAlarmIcon />,
label: `ANOTHER TAB`,
content: <div>Content {index}</div>,
}))}
/>
</div>
);
+165
View File
@@ -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<BackstageTheme>((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<TabsProps> = ({ 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<TabProps[][]>([[]]);
const wrapper = useRef() as MutableRefObject<HTMLDivElement>;
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 (
<div className={classes.root}>
<AppBar ref={wrapper} className={classes.appbar} position="static">
<div>
<StyledTabs value={currentIndex} onChange={handleChange}>
{navIndex !== 0 && (
<StyledIcon
onClick={navigateToPrevChunk}
ariaLabel="navigate-before"
>
<NavigateBeforeIcon />
</StyledIcon>
)}
{chunkedTabs[navIndex].map((tab, index) => (
<StyledTab
value={index}
isFirstIndex={index === 0}
isFirstNav={navIndex === 0}
key={index}
icon={tab.icon || undefined}
label={tab.label || undefined}
/>
))}
{hasNextNavIndex() && (
<StyledIcon
isNext
onClick={navigateToNextChunk}
ariaLabel="navigate-next"
>
<NavigateNextIcon />
</StyledIcon>
)}
</StyledTabs>
</div>
</AppBar>
{currentIndex !== false ? (
chunkedTabs[navIndex].map((tab, index) => (
<TabPanel key={index} value={index} index={currentIndex}>
{tab.content}
</TabPanel>
))
) : (
// Render if the selected tab index is outside the current rendered chunked array
<TabPanel
key="panel_outside_chunked_array"
value={value[1]}
index={value[1]}
>
{chunkedTabs[value[0]][value[1]].content}
</TabPanel>
)}
</div>
);
};
@@ -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';
@@ -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;
};
+1
View File
@@ -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';
+11 -3
View File
@@ -58,7 +58,11 @@ const useStyles = makeStyles<Theme>(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>(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<SidebarItemProps> = ({
</NavLink>
);
}
return (
<NavLink
className={clsx(classes.root, classes.open)}
@@ -166,7 +174,7 @@ export const SidebarItem: FC<SidebarItemProps> = ({
{text}
</Typography>
)}
{children}
<div className={classes.secondaryAction}>{children}</div>
</NavLink>
);
};
@@ -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>(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 (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>
<Skeleton variant="circle" width={40} height={40} />
</ListItemIcon>
<ListItemText
primary={<Skeleton component="span" width={120} />}
secondary={<Skeleton component="span" width={60} />}
/>
<ListItemSecondaryAction>
<IconButton>
<Skeleton variant="circle" width={24} height={24} />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
);
}
// TODO: Not functional yet to sign in from the sidebar
if (!user) {
return (
<ListItem {...props}>
<ListItemIcon style={{ marginRight: 0 }}>{icon}</ListItemIcon>
<ListItemText primary="Sign In" secondary={title} />
<ListItemSecondaryAction>
<Tooltip
title={`Sign in with ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignIn()}>
<ControlPointIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
}
const { id, avatarUrl, avatarAlt } = user;
return (
<ListItem {...props}>
<ListItemAvatar>
<Avatar src={avatarUrl} alt={avatarAlt}>
{avatarAlt && avatarAlt[0].toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
className={classes.listItemText}
primary={
<Typography className={classes.listItemText} variant="body2">
{id}
</Typography>
}
secondary={title}
/>
<ListItemSecondaryAction style={{ marginLeft: '30px' }}>
<Tooltip
title={`Sign out from ${title}`}
placement="bottom-end"
PopperProps={{ style: { width: 120 } }}
>
<IconButton onClick={() => onSignOut()}>
<LogoutIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
};
const useGoogleLoginState = (open: boolean) => {
const googleAuth = useApi(googleAuthApiRef);
const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<ProfileInfo>();
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<Props> = ({
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 (
<>
<List dense>
<ListItem className={classes.root} onClick={handleOpen}>
<ListItemAvatar>
{imageUrl ? (
<Avatar alt={name} src={imageUrl} className={classes.avatar} />
) : (
<Avatar
alt={name}
className={`${classes.avatar} ${classes.purple}`}
>
{avatarFallback[0]}
</Avatar>
)}
</ListItemAvatar>
{!collapsedMode && (
<ListItemText
primary={
<Typography className={classes.listItemText} variant="body2">
{displayName}
</Typography>
}
/>
)}
</ListItem>
</List>
<Popover
transitionDuration={0}
open={state.open}
anchorEl={state.anchorEl}
anchorOrigin={{ horizontal: 'center', vertical: 'top' }}
transformOrigin={{ horizontal: 'center', vertical: 'bottom' }}
onClose={handleClose}
>
<List dense>
<SessionListItem
classes={classes}
loading={googleLogin.loading}
title="Google"
icon={AccountCircleIcon}
user={
googleLogin.isLoggedIn && {
id: googleLogin.profile?.email,
avatarUrl: googleLogin.profile?.picture ?? '',
avatarAlt:
googleLogin.profile?.picture ?? googleLogin.profile?.email,
}
}
onSignIn={handleGoogleSignIn}
onSignOut={handleGoogleSignOut}
/>
</List>
</Popover>
</>
);
};
@@ -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<BackstageTheme, { isPinned: boolean }>(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<BackstageTheme, { isPinned: boolean }>(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<ProfileInfo>();
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 (
<div className={classes.root}>
{profile ? (
<>
<LoggedUserBadge
email={profile.email}
imageUrl={profile.picture}
name={profile.name}
collapsedMode={!isOpen}
/>
</>
) : (
<SidebarItem icon={AccountCircleIcon} text="" disableSelected />
)}
{isOpen && (
<button
className={classes.arrowButtonWrapper}
@@ -0,0 +1,73 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiRef,
OAuthApi,
SessionStateApi,
useApi,
Subscription,
IconComponent,
SessionState,
} from '@backstage/core-api';
import React, { FC, useState, useEffect } from 'react';
import { ProviderSettingsItem } from './ProviderSettingsItem';
type OAuthProviderSidebarProps = {
title: string;
icon: IconComponent;
apiRef: ApiRef<OAuthApi & SessionStateApi>;
};
export const OAuthProviderSettings: FC<OAuthProviderSidebarProps> = ({
title,
icon,
apiRef,
}) => {
const api = useApi(apiRef);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
const checkSession = async () => {
const session = await api.getAccessToken('', { optional: true });
setSignedIn(!!session);
};
let subscription: Subscription;
const observeSession = () => {
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
});
};
checkSession();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [api]);
return (
<ProviderSettingsItem
title={title}
icon={icon}
signedIn={signedIn}
api={api}
signInHandler={() => api.getAccessToken()}
/>
);
};
@@ -0,0 +1,74 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ApiRef,
OpenIdConnectApi,
SessionStateApi,
useApi,
Subscription,
IconComponent,
SessionState,
} from '@backstage/core-api';
import React, { FC, useState, useEffect } from 'react';
import { ProviderSettingsItem } from './ProviderSettingsItem';
export type OIDCProviderSidebarProps = {
title: string;
icon: IconComponent;
apiRef: ApiRef<OpenIdConnectApi & SessionStateApi>;
};
export const OIDCProviderSettings: FC<OIDCProviderSidebarProps> = ({
title,
icon,
apiRef,
}) => {
const api = useApi(apiRef);
const [signedIn, setSignedIn] = useState(false);
useEffect(() => {
const checkSession = async () => {
const session = await api.getIdToken({ optional: true });
setSignedIn(!!session);
};
let subscription: Subscription;
const observeSession = () => {
subscription = api
.sessionState$()
.subscribe((sessionState: SessionState) => {
setSignedIn(sessionState === SessionState.SignedIn);
});
};
checkSession();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [api]);
return (
<ProviderSettingsItem
title={title}
icon={icon}
signedIn={signedIn}
api={api}
signInHandler={() => api.getIdToken()}
/>
);
};
@@ -0,0 +1,49 @@
/*
* 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 { OAuthApi, OpenIdConnectApi, IconComponent } from '@backstage/core-api';
import { SidebarItem } from '../Items';
import { IconButton, Tooltip } from '@material-ui/core';
import StarBorder from '@material-ui/icons/StarBorder';
import PowerButton from '@material-ui/icons/PowerSettingsNew';
export const ProviderSettingsItem: FC<{
title: string;
icon: IconComponent;
signedIn: boolean;
api: OAuthApi | OpenIdConnectApi;
signInHandler: Function;
}> = ({ title, icon, signedIn, api, signInHandler }) => {
return (
<SidebarItem
key={title}
text={title}
icon={icon ?? StarBorder}
disableSelected
>
<IconButton onClick={() => (signedIn ? api.logout() : signInHandler())}>
<Tooltip
placement="top"
arrow
title={signedIn ? `Sign out from ${title}` : `Sign in to ${title}`}
>
<PowerButton color={signedIn ? 'secondary' : 'primary'} />
</Tooltip>
</IconButton>
</SidebarItem>
);
};
@@ -0,0 +1,112 @@
/*
* 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, useRef, useEffect } from 'react';
import { makeStyles, Avatar, Divider } from '@material-ui/core';
import {
ProfileInfo,
useApi,
googleAuthApiRef,
Subscription,
SessionState,
} from '@backstage/core-api';
import { SidebarItem } from '../Items';
import ExpandLess from '@material-ui/icons/ExpandLess';
import ExpandMore from '@material-ui/icons/ExpandMore';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
const useStyles = makeStyles({
avatar: {
width: 24,
height: 24,
},
});
export const UserProfile: FC<{ open: boolean; setOpen: Function }> = ({
open,
setOpen,
}) => {
const [profile, setProfile] = useState<ProfileInfo>();
const ref = useRef<Element>(); // for scrolling down when collapse item opens
const googleAuth = useApi(googleAuthApiRef);
const classes = useStyles();
const handleClick = () => {
setOpen(!open);
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
};
useEffect(() => {
const fetchProfile = async () => {
await googleAuth
.getProfile({ optional: true })
.then((userProfile?: ProfileInfo) => {
setProfile(userProfile);
});
};
let subscription: Subscription;
const observeSession = () => {
subscription = googleAuth
.sessionState$()
.subscribe(async (sessionState: SessionState) => {
if (sessionState === SessionState.SignedIn) {
await fetchProfile();
} else {
setProfile(undefined);
}
});
};
fetchProfile();
observeSession();
return () => {
subscription.unsubscribe();
};
}, [googleAuth]);
// Handle main auth info that is shown on the collapsible SidebarItem
let avatar;
let displayName = 'Guest';
if (profile) {
const email = profile.email;
const name = profile.name;
const imageUrl = profile.picture;
const emailTrimmed = email.split('@')[0];
const displayEmail =
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
displayName = name ?? displayEmail;
avatar = imageUrl
? () => (
<Avatar alt={displayName} src={imageUrl} className={classes.avatar} />
)
: () => <Avatar alt={displayName} className={classes.avatar} />;
}
return (
<>
<Divider innerRef={ref} />
<SidebarItem
text={displayName}
onClick={handleClick}
icon={avatar || AccountCircleIcon}
disableSelected
>
{open ? <ExpandLess /> : <ExpandMore />}
</SidebarItem>
</>
);
};
@@ -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.
*/
export { ProviderSettingsItem } from './ProviderSettingsItem';
export { OAuthProviderSettings } from './OAuthProviderSettings';
export { OIDCProviderSettings } from './OIDCProviderSettings';
export { UserProfile } from './UserProfile';
@@ -22,7 +22,7 @@ import {
SidebarDivider,
SidebarSearchField,
SidebarSpace,
SidebarUserBadge,
SidebarUserSettings,
} from '.';
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
@@ -55,6 +55,6 @@ export const SampleSidebar = () => (
<SidebarIntro />
<SidebarSpace />
<SidebarDivider />
<SidebarUserBadge />
<SidebarUserSettings />
</Sidebar>
);
@@ -0,0 +1,54 @@
/*
* 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, { useContext, useEffect } from 'react';
import Collapse from '@material-ui/core/Collapse';
import Star from '@material-ui/icons/Star';
import { SidebarContext } from './config';
import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
import {
OAuthProviderSettings,
OIDCProviderSettings,
UserProfile as SidebarUserProfile,
} from './Settings';
export function SidebarUserSettings() {
const { isOpen: sidebarOpen } = useContext(SidebarContext);
const [open, setOpen] = React.useState(false);
// Close the provider list when sidebar collapse
useEffect(() => {
if (!sidebarOpen && open) setOpen(false);
}, [open, sidebarOpen]);
return (
<>
<SidebarUserProfile open={open} setOpen={setOpen} />
<Collapse in={open} timeout="auto">
<OIDCProviderSettings
title="Google"
apiRef={googleAuthApiRef}
icon={Star}
/>
<OAuthProviderSettings
title="Github"
apiRef={githubAuthApiRef}
icon={Star}
/>
</Collapse>
</>
);
}
+3 -1
View File
@@ -25,7 +25,7 @@ export {
SidebarSpacer,
} from './Items';
export { IntroCard, SidebarIntro } from './Intro';
export { SidebarUserBadge } from './UserBadge';
export { SidebarPinButton } from './PinButton';
export {
SIDEBAR_INTRO_LOCAL_STORAGE,
SidebarContext,
@@ -33,3 +33,5 @@ export {
} from './config';
export type { SidebarContextType } from './config';
export { SidebarThemeToggle } from './SidebarThemeToggle';
export { SidebarUserSettings } from './UserSettings';
export * from './Settings';
+11
View File
@@ -5,10 +5,12 @@ import {
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -31,4 +33,13 @@ builder.add(
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+9 -3
View File
@@ -57,8 +57,11 @@ export const lightTheme = createTheme({
gold: yellow.A700,
sidebar: '#171717',
pinSidebarButton: {
icon: '#BDBDBD',
background: '#404040',
icon: '#181818',
background: '#BDBDBD',
},
tabbar: {
indicator: '#9BF0E1',
},
},
});
@@ -103,8 +106,11 @@ export const darkTheme = createTheme({
gold: yellow.A700,
sidebar: '#424242',
pinSidebarButton: {
icon: '#181818',
icon: '#404040',
background: '#BDBDBD',
},
tabbar: {
indicator: '#9BF0E1',
},
},
});
+3
View File
@@ -44,6 +44,9 @@ type PaletteAdditions = {
link: string;
gold: string;
sidebar: string;
tabbar: {
indicator: string;
};
bursts: {
fontColor: string;
slackChannelText: string;
+5
View File
@@ -7,6 +7,11 @@ This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
## Getting Started
After starting the backend, you can issue the `yarn mock-catalog-data` command
in this directory to populate the catalog with some mock entities.
## Links
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
@@ -0,0 +1,60 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: podcast-api
description: Podcast API
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-lookup
description: Artist Lookup
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: searcher
description: Searcher
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playback-order
description: Playback Order
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: shuffle-api
description: Shuffle API
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: queue-proxy
description: Queue Proxy
spec:
type: website
lifecycle: production
owner: tools@example.com
@@ -1,6 +0,0 @@
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: component3
spec:
type: service
@@ -1,14 +0,0 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playlist-proxy
spec:
type: service
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-web
spec:
type: website
+1 -4
View File
@@ -13,18 +13,15 @@
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean",
"mock-data": "./scripts/mock-data"
"mock-catalog-data": "./scripts/mock-data"
},
"dependencies": {
"@backstage/backend-common": "^0.1.1-alpha.7",
"@backstage/catalog-model": "^0.1.1-alpha.7",
"compression": "^1.7.4",
"cors": "^2.8.5",
"esm": "^3.2.25",
"express": "^4.17.1",
"express-promise-router": "^3.0.3",
"fs-extra": "^9.0.0",
"helmet": "^3.22.0",
"knex": "^0.21.1",
"lodash": "^4.17.15",
"morgan": "^1.10.0",
+1 -1
View File
@@ -5,5 +5,5 @@ curl \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml"
}'
@@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => {
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
@@ -15,6 +15,9 @@
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
await this.database.removeEntity(tx, uid);
const entityResponse = await this.database.entityByUid(tx, uid);
if (!entityResponse) {
throw new NotFoundError(`Entity with ID ${uid} was not found`);
}
const location =
entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION];
const colocatedEntities = location
? await this.database.entities(tx, [
{
key: LOCATION_ANNOTATION,
values: [location],
},
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
}
if (entityResponse.locationId) {
await this.database.removeLocation(tx, entityResponse?.locationId!);
}
return undefined;
});
}
@@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async removeLocation(id: string): Promise<void> {
await this.database.removeLocation(id);
await this.database.transaction(tx => this.database.removeLocation(tx, id));
}
async locations(): Promise<LocationResponse[]> {
@@ -105,8 +105,7 @@ describe('CommonDatabase', () => {
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(location).toEqual(output);
await db.removeLocation(locations[0].id);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
@@ -319,6 +319,21 @@ export class CommonDatabase implements Database {
return toEntityResponse(rows[0]);
}
async entityByUid(
txOpaque: unknown,
id: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
if (rows.length !== 1) {
return undefined;
}
return toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
@@ -341,10 +356,10 @@ export class CommonDatabase implements Database {
});
}
async removeLocation(id: string): Promise<void> {
const result = await this.database<DbLocationsRow>('locations')
.where({ id })
.del();
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
@@ -130,11 +130,13 @@ export type Database = {
namespace?: string,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(id: string): Promise<void>;
removeLocation(tx: unknown, id: string): Promise<void>;
location(id: string): Promise<DbLocationsRowWithStatus>;
@@ -1,71 +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 {
errorHandler,
notFoundHandler,
requestLoggingHandler,
} from '@backstage/backend-common';
import compression from 'compression';
import cors from 'cors';
import express from 'express';
import helmet from 'helmet';
import { Logger } from 'winston';
import { EntitiesCatalog, LocationsCatalog } from '../catalog';
import { HigherOrderOperation } from '../ingestion';
import { createRouter } from './router';
export interface ApplicationOptions {
enableCors: boolean;
entitiesCatalog: EntitiesCatalog;
locationsCatalog?: LocationsCatalog;
higherOrderOperation?: HigherOrderOperation;
logger: Logger;
}
export async function createStandaloneApplication(
options: ApplicationOptions,
): Promise<express.Application> {
const {
enableCors,
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
} = options;
const app = express();
app.use(helmet());
if (enableCors) {
app.use(cors());
}
app.use(compression());
app.use(express.json());
app.use(requestLoggingHandler());
app.use(
'/catalog',
await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
}),
);
app.use(notFoundHandler());
app.use(errorHandler());
return app;
}
@@ -14,13 +14,15 @@
* limitations under the License.
*/
import { createServiceBuilder } from '@backstage/backend-common';
import { Server } from 'http';
import { Logger } from 'winston';
import { HigherOrderOperations } from '..';
import { DatabaseEntitiesCatalog } from '../catalog/DatabaseEntitiesCatalog';
import { DatabaseLocationsCatalog } from '../catalog/DatabaseLocationsCatalog';
import { DatabaseManager } from '../database/DatabaseManager';
import { HigherOrderOperations, LocationReaders } from '../ingestion';
import { createStandaloneApplication } from './standaloneApplication';
import { createRouter } from './router';
import { LocationReaders } from '../ingestion';
export interface ServerOptions {
port: number;
@@ -33,11 +35,11 @@ export async function startStandaloneServer(
): Promise<Server> {
const logger = options.logger.child({ service: 'catalog-backend' });
logger.debug('Creating application...');
const db = await DatabaseManager.createInMemoryDatabase(logger);
const entitiesCatalog = new DatabaseEntitiesCatalog(db);
const locationsCatalog = new DatabaseLocationsCatalog(db);
const locationReader = new LocationReaders(options.logger);
const locationReader = new LocationReaders();
const higherOrderOperation = new HigherOrderOperations(
entitiesCatalog,
locationsCatalog,
@@ -45,25 +47,18 @@ export async function startStandaloneServer(
logger,
);
logger.debug('Creating application...');
const app = await createStandaloneApplication({
enableCors: options.enableCors,
logger.debug('Starting application server...');
const router = await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger,
});
logger.debug('Starting application server...');
return await new Promise((resolve, reject) => {
const server = app.listen(options.port, (err?: Error) => {
if (err) {
reject(err);
return;
}
logger.info(`Listening on port ${options.port}`);
resolve(server);
});
const service = createServiceBuilder()
.enableCors({ origin: 'http://localhost:3000' })
.addRouter('/catalog', router);
return await service.start().catch(err => {
logger.error(err);
process.exit(1);
});
}
+1
View File
@@ -30,6 +30,7 @@
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"node-cache": "^5.1.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-router": "^5.2.0",
+28 -40
View File
@@ -13,56 +13,44 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { CatalogClient } from './CatalogClient';
import { DescriptorEnvelope } from '../types';
const server = setupServer();
describe('CatalogClient', () => {
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const mockApiOrigin = 'http://backstage:9191';
const mockBasePath = '/i-am-a-mock-base';
let client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
describe('getEntities', () => {
it('should return the json response for the correct path', async () => {
const mockApiOrigin = 'http://backstage:9191';
const mockBasePath = '/i-am-a-mock-base';
const client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
const mockDescriptors: DescriptorEnvelope[] = [
{
apiVersion: '1',
kind: 'Component',
metadata: {
description: 'Im a description',
name: 'Test1',
namespace: 'test1',
},
},
{
apiVersion: '1',
kind: 'Component',
metadata: {
description: 'Im a description',
name: 'Test2',
namespace: 'test1',
},
},
];
server.use(
rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (_, res, ctx) => {
return res(ctx.json(mockDescriptors));
}),
);
const entities = await client.getEntities();
expect(entities).toEqual(mockDescriptors);
beforeEach(() => {
client = new CatalogClient({
apiOrigin: mockApiOrigin,
basePath: mockBasePath,
});
});
it('builds entity search filters properly', async () => {
expect.assertions(2);
server.use(
rest.get(`${mockApiOrigin}${mockBasePath}/entities`, (req, res, ctx) => {
expect(req.url.searchParams.toString()).toBe('a=1&b=2&b=3&%C3%B6=%3D');
return res(ctx.json([]));
}),
);
const entities = await client.getEntities({
a: '1',
b: ['2', '3'],
ö: '=',
});
expect(entities).toEqual([]);
});
});
+86 -39
View File
@@ -14,17 +14,22 @@
* limitations under the License.
*/
import { CatalogApi } from './types';
import { DescriptorEnvelope } from '../types';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Cache from 'node-cache';
import { CatalogApi, EntityCompoundName } from './types';
export class CatalogClient implements CatalogApi {
// TODO(blam): This cache is just temporary until we have GraphQL.
// And client side caching using things like React Apollo or Relay.
// There's a lot of loading states that cause flickering around the app which aren't needed.
private cache: Cache;
private apiOrigin: string;
private basePath: string;
constructor({
apiOrigin,
basePath,
@@ -34,47 +39,74 @@ export class CatalogClient implements CatalogApi {
}) {
this.apiOrigin = apiOrigin;
this.basePath = basePath;
this.cache = new Cache({ stdTTL: 10 });
}
async getLocationById(id: String): Promise<Location | undefined> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${id}`,
);
if (response.ok) {
const location = await response.json();
if (location) return location.data;
}
return undefined;
}
async getEntities(
filter?: Record<string, string>,
): Promise<DescriptorEnvelope[]> {
let url = `${this.apiOrigin}${this.basePath}/entities`;
if (filter) {
url += '?';
url += Object.entries(filter)
.map(
([key, value]) =>
`${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
)
.join('&');
}
private async getRequired(path: string): Promise<any> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const response = await fetch(url);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
async getEntityByName(name: string): Promise<DescriptorEnvelope> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-name/Component/default/${name}`,
private async getOptional(path: string): Promise<any | undefined> {
const url = `${this.apiOrigin}${this.basePath}${path}`;
const response = await fetch(url);
if (!response.ok) {
if (response.status === 404) {
return undefined;
}
const payload = await response.text();
const message = `Request failed with ${response.status} ${response.statusText}, ${payload}`;
throw new Error(message);
}
return await response.json();
}
async getLocationById(id: String): Promise<Location | undefined> {
return await this.getOptional(`/locations/${id}`);
}
async getEntities(
filter?: Record<string, string | string[]>,
): Promise<Entity[]> {
const cachedValue = this.cache.get<Entity[]>(
`get:${JSON.stringify(filter)}`,
);
const entity = await response.json();
if (entity) return entity;
throw new Error(`'Entity not found: ${name}`);
if (cachedValue) return cachedValue;
let path = `/entities`;
if (filter) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filter)) {
if (Array.isArray(value)) {
for (const v of value) {
params.append(key, v);
}
} else {
params.append(key, value);
}
}
path += `?${params.toString()}`;
}
return await this.getRequired(path);
}
async getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined> {
const { kind, namespace = 'default', name } = compoundName;
return this.getOptional(`/entities/by-name/${kind}/${namespace}/${name}`);
}
async addLocation(type: string, target: string) {
@@ -105,11 +137,26 @@ export class CatalogClient implements CatalogApi {
}
async getLocationByEntity(entity: Entity): Promise<Location | undefined> {
const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION];
if (!locationId) return undefined;
const locationCompound = entity.metadata.annotations?.[LOCATION_ANNOTATION];
const all: { data: Location }[] = await this.getRequired('/locations');
return all
.map(r => r.data)
.find(l => locationCompound === `${l.type}:${l.target}`);
}
const location = this.getLocationById(locationId);
return location;
async removeEntityByUid(uid: string): Promise<void> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities/by-uid/${uid}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
const payload = await response.text();
throw new Error(
`Request failed with ${response.status} ${response.statusText}, ${payload}`,
);
}
return undefined;
}
}
+12 -2
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { Entity, Location } from '@backstage/catalog-model';
@@ -22,12 +23,21 @@ export const catalogApiRef = createApiRef<CatalogApi>({
'Used by the Catalog plugin to make requests to accompanying backend',
});
export type EntityCompoundName = {
kind: string;
namespace?: string;
name: string;
};
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
getEntityByName(
compoundName: EntityCompoundName,
): Promise<Entity | undefined>;
getEntities(filter?: Record<string, string | string[]>): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
removeEntityByUid(uid: string): Promise<void>;
}
export type AddLocationResponse = { location: Location; entities: Entity[] };
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useApi } from '@backstage/core';
import { catalogApiRef } from '../../api/types';
@@ -18,7 +18,7 @@ import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import { wrapInTestApp } from '@backstage/test-utils';
import { CatalogFilter, CatalogFilterGroup } from './CatalogFilter';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
describe('Catalog Filter', () => {
it('should render the different groups', async () => {
@@ -41,11 +41,11 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
},
],
@@ -68,12 +68,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -97,12 +97,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: 100,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -136,12 +136,12 @@ describe('Catalog Filter', () => {
name: 'Test Group 1',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'First Label',
count: () => <b>BACKSTAGE!</b>,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Second Label',
count: 400,
},
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import {
Card,
@@ -25,9 +26,9 @@ import {
makeStyles,
} from '@material-ui/core';
import type { IconComponent } from '@backstage/core';
import { FilterGroupItem } from '../../types';
import { EntityFilterType } from '../../data/filters';
export type CatalogFilterItem = {
id: FilterGroupItem;
id: EntityFilterType;
label: string;
icon?: IconComponent;
count?: number | React.FC;
@@ -24,6 +24,8 @@ describe('Starred Count', () => {
it('should render the count returned from the hook', async () => {
jest.spyOn(Hooks, 'useStarredEntities').mockReturnValue({
starredEntities: new Set(['id1', 'id2', 'id3', 'id4']),
isStarredEntity: () => false,
toggleStarredEntity: () => undefined,
});
const { findByText } = render(wrapInTestApp(<StarredCount />));
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { useStarredEntities } from '../../hooks/useStarredEntites';
@@ -14,19 +14,19 @@
* limitations under the License.
*/
import React from 'react';
import { render } from '@testing-library/react';
import CatalogPage from './CatalogPage';
import {
ApiRegistry,
ApiProvider,
ApiRegistry,
errorApiRef,
storageApiRef,
WebStorage,
} from '@backstage/core';
import { wrapInTestApp, MockErrorApi } from '@backstage/test-utils';
import { MockErrorApi, wrapInTestApp } from '@backstage/test-utils';
import { render } from '@testing-library/react';
import React from 'react';
import { catalogApiRef } from '../..';
import { CatalogApi } from '../../api/types';
import { CatalogPage } from './CatalogPage';
import { Entity } from '@backstage/catalog-model';
describe('CatalogPage', () => {
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import {
Content,
ContentHeader,
@@ -26,26 +27,24 @@ import {
SupportButton,
useApi,
} from '@backstage/core';
import { LocationSpec } from '@backstage/catalog-model';
import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder';
import { Button, makeStyles, Typography, Link } from '@material-ui/core';
import GitHub from '@material-ui/icons/GitHub';
import { Button, Link, makeStyles, Typography } from '@material-ui/core';
import Edit from '@material-ui/icons/Edit';
import GitHub from '@material-ui/icons/GitHub';
import Star from '@material-ui/icons/Star';
import StarOutline from '@material-ui/icons/StarBorder';
import React, { FC, useCallback, useState } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { useAsync } from 'react-use';
import { catalogApiRef } from '../..';
import { Component } from '../../data/component';
import { defaultFilter, filterGroups, dataResolvers } from '../../data/filters';
import { entityToComponent, findLocationForEntityMeta } from '../../data/utils';
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
import { findLocationForEntityMeta } from '../../data/utils';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import {
CatalogFilter,
CatalogFilterItem,
} from '../CatalogFilter/CatalogFilter';
import { useStarredEntities } from '../../hooks/useStarredEntites';
import CatalogTable from '../CatalogTable/CatalogTable';
import { CatalogTable } from '../CatalogTable/CatalogTable';
const useStyles = makeStyles(theme => ({
contentWrapper: {
@@ -60,16 +59,22 @@ const useStyles = makeStyles(theme => ({
},
}));
const CatalogPage: FC<{}> = () => {
export const CatalogPage: FC<{}> = () => {
const catalogApi = useApi(catalogApiRef);
const { starredEntities } = useStarredEntities();
const {
starredEntities,
toggleStarredEntity,
isStarredEntity,
} = useStarredEntities();
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
defaultFilter,
);
const { value, error, loading } = useAsync(
() => dataResolvers[selectedFilter.id]({ catalogApi, starredEntities }),
[selectedFilter.id],
);
const { value, error, loading } = useAsync(async () => {
const filter = entityFilters[selectedFilter.id];
const all = await catalogApi.getEntities();
return all.filter(e => filter(e, { isStarred: isStarredEntity(e) }));
}, [selectedFilter.id, starredEntities.size]);
const onFilterSelected = useCallback(
selected => setSelectedFilter(selected),
@@ -79,7 +84,7 @@ const CatalogPage: FC<{}> = () => {
const styles = useStyles();
const actions = [
(rowData: Component) => {
(rowData: Entity) => {
const location = findLocationForEntityMeta(rowData.metadata);
return {
icon: GitHub,
@@ -88,10 +93,10 @@ const CatalogPage: FC<{}> = () => {
if (!location) return;
window.open(location.target, '_blank');
},
hidden: location ? location?.type !== 'github' : true,
hidden: location?.type !== 'github',
};
},
(rowData: Component) => {
(rowData: Entity) => {
const createEditLink = (location: LocationSpec): string => {
switch (location.type) {
case 'github':
@@ -111,7 +116,15 @@ const CatalogPage: FC<{}> = () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
},
hidden: location ? location?.type !== 'github' : true,
hidden: location?.type !== 'github',
};
},
(rowData: Entity) => {
const isStarred = isStarredEntity(rowData);
return {
icon: isStarred ? Star : StarOutline,
tooltip: isStarred ? 'Remove from favorites' : 'Add to favorites',
onClick: () => toggleStarredEntity(rowData),
};
},
];
@@ -170,7 +183,7 @@ const CatalogPage: FC<{}> = () => {
>
Create Service
</Button>
<SupportButton>All your components</SupportButton>
<SupportButton>All your software catalog entities</SupportButton>
</ContentHeader>
<div className={styles.contentWrapper}>
<div>
@@ -182,16 +195,7 @@ const CatalogPage: FC<{}> = () => {
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
locationSpec: findLocationForEntityMeta(val.metadata),
};
})) ||
[]
}
entities={value || []}
loading={loading}
error={error}
actions={actions}
@@ -201,5 +205,3 @@ const CatalogPage: FC<{}> = () => {
</Page>
);
};
export default CatalogPage;
@@ -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(
<CatalogTable
titlePreamble="Owned"
components={[]}
entities={[]}
loading={false}
error={{ code: 'error' }}
/>,
),
);
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(
<CatalogTable
titlePreamble="Owned"
components={components}
entities={entites}
loading={false}
/>,
),
);
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();
@@ -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) => (
<Link
component={RouterLink}
to={generatePath(entityRoute.path, { name: componentData.name })}
to={generatePath(entityRoute.path, {
optionalNamespaceAndName: [
entity.metadata.namespace,
entity.metadata.name,
]
.filter(Boolean)
.join(':'),
kind: entity.kind,
})}
>
{componentData.name}
{entity.metadata.name}
</Link>
),
},
{
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<CatalogTableProps> = ({
components,
export const CatalogTable: FC<CatalogTableProps> = ({
entities,
loading,
error,
titlePreamble,
@@ -65,7 +77,7 @@ const CatalogTable: FC<CatalogTableProps> = ({
return (
<div>
<Alert severity="error">
Error encountered while fetching components. {error.toString()}
Error encountered while fetching catalog entities. {error.toString()}
</Alert>
</div>
);
@@ -81,11 +93,9 @@ const CatalogTable: FC<CatalogTableProps> = ({
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;
@@ -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<ComponentPageProps> = ({ 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<ErrorApi>(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const { value: component, error, loading } = useAsync<Component>(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, ...)
<Page theme={pageTheme.service}>
<Header title={component?.name || 'Catalog'} type="Service">
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
<HeaderTabs tabs={tabs} />
{confirmationDialogOpen && component && (
<ComponentRemovalDialog
component={component}
onClose={hideRemovalDialog}
onConfirm={removeComponent}
onCancel={hideRemovalDialog}
/>
)}
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard
loading={loading || removingPending}
component={component}
/>
</Grid>
<Grid item>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
</Page>
);
};
export default ComponentPage;
@@ -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(
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
<EntityContextMenu onUnregisterEntity={mockCallback} />,
);
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();
});
});
@@ -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<ComponentContextMenuProps> = ({
onUnregisterComponent,
}) => {
export const EntityContextMenu: FC<Props> = ({ onUnregisterEntity }) => {
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
const classes = useStyles();
@@ -53,7 +52,7 @@ const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
};
return (
<div>
<>
<IconButton
aria-label="more"
aria-controls="long-menu"
@@ -75,13 +74,13 @@ const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
<MenuItem
onClick={() => {
onClose();
onUnregisterComponent();
onUnregisterEntity();
}}
>
<ListItemIcon>
<Cancel fontSize="small" />
</ListItemIcon>
<Typography variant="inherit">Unregister component</Typography>
<Typography variant="inherit">Unregister entity</Typography>
</MenuItem>
<MenuItem>
<ListItemIcon>
@@ -91,8 +90,6 @@ const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
</MenuItem>
</MenuList>
</Popover>
</div>
</>
);
};
export default ComponentContextMenu;
@@ -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(
<ComponentMetadataCard loading={false} component={testComponent} />,
);
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
expect(await rendered.findByText('test')).toBeInTheDocument();
});
it('should display loader when loading is set to true', async () => {
const rendered = await render(
<ComponentMetadataCard loading component={undefined} />,
);
expect(await rendered.findByRole('progressbar')).toBeInTheDocument();
});
});
@@ -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<Props> = ({ entity }) => (
<InfoCard title="Metadata">
<StructuredMetadataTable metadata={entity.metadata} />
</InfoCard>
);
@@ -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<CatalogApi>) as CatalogApi,
],
])}
>
<ComponentPage {...props} />
<EntityPage {...props} />
</ApiProvider>,
),
);
@@ -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<Props> = ({ 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<Entity | undefined>(
() => 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, ...)
<Page theme={pageTheme.service}>
<Header title={headerTitle} type={headerType}>
{entity && <EntityContextMenu onUnregisterEntity={showRemovalDialog} />}
</Header>
{loading && <Progress />}
{error && (
<Content>
<Alert severity="error">{error.toString()}</Alert>
</Content>
)}
{entity && (
<>
<HeaderTabs tabs={tabs} />
<Content>
<Grid container spacing={3} direction="column">
<Grid item>
<EntityMetadataCard entity={entity} />
</Grid>
<Grid item>
<SentryIssuesWidget
sentryProjectId="sample-sentry-project-id"
statsFor="24h"
/>
</Grid>
</Grid>
</Content>
<UnregisterEntityDialog
open={confirmationDialogOpen}
entity={entity}
onConfirm={cleanUpAfterRemoval}
onClose={() => setConfirmationDialogOpen(false)}
/>
</>
)}
</Page>
);
};
@@ -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<Entity[]> {
function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
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<ComponentRemovalDialogProps> = ({
export const UnregisterEntityDialog: FC<Props> = ({
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 (
<Dialog fullScreen={fullScreen} open onClose={onClose}>
<Dialog fullScreen={fullScreen} open={open} onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
Are you sure you want to unregister this entity?
</DialogTitle>
<DialogContent>
{loading ? <Progress /> : null}
@@ -91,21 +103,23 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
<Typography component="div">
<ul>
<li>
{entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]}
{entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
</li>
</ul>
</Typography>
<DialogContentText>
To undo, just re-register the component in Backstage.
To undo, just re-register the entity in Backstage.
</DialogContentText>
</>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={onCancel}>Cancel</Button>
<Button onClick={onClose} color="primary">
Cancel
</Button>
<Button
disabled={!!(loading || error)}
onClick={onConfirm}
onClick={removeEntity}
color="secondary"
>
Unregister
@@ -114,5 +128,3 @@ const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
</Dialog>
);
};
export default ComponentRemovalDialog;
+22 -27
View File
@@ -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<string>;
}) => Promise<Entity[]>;
type EntityFilter = (entity: Entity, options: EntityFilterOptions) => boolean;
export const dataResolvers: Record<FilterGroupItem, ResolverFunction> = {
[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<string, EntityFilter> = {
[EntityFilterType.OWNED]: () => false,
[EntityFilterType.ALL]: () => true,
[EntityFilterType.STARRED]: (_, { isStarred }) => isStarred,
};
export const defaultFilter: CatalogFilterItem = filterGroups[0].items[0];
+2 -12
View File
@@ -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,
+36 -4
View File
@@ -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<string[]>('starredEntities') ?? [];
const rawStarredEntityKeys =
settingsStore.get<string[]>('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,
};
};
@@ -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 (
<ApiProvider apis={ApiRegistry.with(storageApiRef, mockStorage)}>
@@ -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();
});
});
-1
View File
@@ -17,5 +17,4 @@
export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
export * from './routes';
+4 -4
View File
@@ -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);
},
});
+1 -1
View File
@@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({
});
export const entityRoute = createRouteRef({
icon: NoIcon,
path: '/catalog/:name/',
path: '/catalog/:kind/:optionalNamespaceAndName/',
title: 'Entity',
});
-165
View File
@@ -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<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* 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<DescriptorEnvelope>;
};
/**
* 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<DescriptorEnvelope | undefined>;
};
export enum FilterGroupItem {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
@@ -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 (
<Page theme={pageTheme.tool}>
<Header
pageTitleOverride="Circle CI"
title={
<Box display="flex" alignItems="center">
<Box mr={1} /> Circle CI
</Box>
}
>
<HeaderLabel label="Owner" value="Team X" />
<Header title="CircleCI" subtitle="See recent builds and their status">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
{children}
@@ -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<{}> = () => {
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+26
View File
@@ -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.
+20
View File
@@ -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();
+49
View File
@@ -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}"
]
}
+170
View File
@@ -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<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
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<T = any>(path: string, init?: RequestInit): Promise<T> {
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<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async changeClusterState(req: ChangeClusterStateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/state', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/clone-from-template', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async applyProfiles(req: ApplyProfileRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/profiles', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async listClusters(
req: ListClusterRequest,
): Promise<ListClusterStatusesResponse> {
return await this.fetch<ListClusterStatusesResponse>('/api/clusters', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
}
@@ -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<ListClusterStatusesResponse>(
() => {
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
});
},
);
let content: JSX.Element;
if (loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (error) {
content = (
<Content>
<Typography variant="h4" color="error">
Failed to load cluster, {String(error)}
</Typography>
</Content>
);
} else {
content = (
<Content>
<ContentHeader title="Clusters">
<Button
variant="contained"
color="primary"
href="/gitops-cluster-create"
>
Create GitOps-managed Cluster
</Button>
<SupportButton>All clusters</SupportButton>
</ContentHeader>
<ClusterTable components={value!.result} />
</Content>
);
}
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
{content}
</Page>
);
};
export default ClusterList;
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { default } from './CatalogPage';
export { default } from './ClusterList';

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