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:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+8
-8
@@ -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>;
|
||||
};
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
+20
-25
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+7
-32
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +44,9 @@ type PaletteAdditions = {
|
||||
link: string;
|
||||
gold: string;
|
||||
sidebar: string;
|
||||
tabbar: {
|
||||
indicator: string;
|
||||
};
|
||||
bursts: {
|
||||
fontColor: string;
|
||||
slackChannelText: string;
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
+7
-6
@@ -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();
|
||||
});
|
||||
});
|
||||
+9
-12
@@ -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;
|
||||
+10
-18
@@ -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>
|
||||
);
|
||||
+12
-10
@@ -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>
|
||||
);
|
||||
};
|
||||
+32
-20
@@ -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;
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,5 +17,4 @@
|
||||
export { plugin } from './plugin';
|
||||
export * from './api/CatalogClient';
|
||||
export * from './api/types';
|
||||
export * from './types';
|
||||
export * from './routes';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,6 @@ export const rootRoute = createRouteRef({
|
||||
});
|
||||
export const entityRoute = createRouteRef({
|
||||
icon: NoIcon,
|
||||
path: '/catalog/:name/',
|
||||
path: '/catalog/:kind/:optionalNamespaceAndName/',
|
||||
title: 'Entity',
|
||||
});
|
||||
|
||||
@@ -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<{}> = () => {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
extends: [require.resolve('@backstage/cli/config/eslint')],
|
||||
};
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
@@ -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}"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user