Merge branch 'master' of github.com:spotify/backstage into blam/react-router
* 'master' of github.com:spotify/backstage: (45 commits) 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 fix(catalog): moar clean up fix(catalog): make code intention clear by renaming Merge pull request #1214 from spotify/feat/star-components Merge pull request #1194 from spotify/freben/backend-common-service Merge pull request #1215 from spotify/mob/sidebar-auth ...
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",
|
||||
|
||||
@@ -44,6 +44,8 @@ import { techRadarApiRef, TechRadar } from '@backstage/plugin-tech-radar';
|
||||
import { CircleCIApi, circleCIApiRef } from '@backstage/plugin-circleci';
|
||||
import { catalogApiRef, CatalogClient } from '@backstage/plugin-catalog';
|
||||
|
||||
import { gitOpsApiRef, GitOpsRestApi } from '@backstage/plugin-gitops-profiles';
|
||||
|
||||
const builder = ApiRegistry.builder();
|
||||
|
||||
const alertApi = builder.add(alertApiRef, new AlertApiForwarder());
|
||||
@@ -97,4 +99,6 @@ builder.add(
|
||||
}),
|
||||
);
|
||||
|
||||
builder.add(gitOpsApiRef, new GitOpsRestApi('http://localhost:3008'));
|
||||
|
||||
export default builder.build() as ApiHolder;
|
||||
|
||||
@@ -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';
|
||||
@@ -88,6 +91,9 @@ 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 />
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 `packages/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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,8 +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',
|
||||
|
||||
@@ -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,6 +22,7 @@ import {
|
||||
SidebarDivider,
|
||||
SidebarSearchField,
|
||||
SidebarSpace,
|
||||
SidebarUserSettings,
|
||||
} from '.';
|
||||
import HomeOutlinedIcon from '@material-ui/icons/HomeOutlined';
|
||||
import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline';
|
||||
@@ -54,5 +55,6 @@ export const SampleSidebar = () => (
|
||||
<SidebarIntro />
|
||||
<SidebarSpace />
|
||||
<SidebarDivider />
|
||||
<SidebarUserSettings />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -14,174 +14,40 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useState, useContext, useEffect, useRef } from 'react';
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import Collapse from '@material-ui/core/Collapse';
|
||||
import ExpandLess from '@material-ui/icons/ExpandLess';
|
||||
import ExpandMore from '@material-ui/icons/ExpandMore';
|
||||
import StarBorder from '@material-ui/icons/StarBorder';
|
||||
import Star from '@material-ui/icons/Star';
|
||||
import { SidebarContext } from './config';
|
||||
import { SidebarItem } from './Items';
|
||||
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
|
||||
import Divider from '@material-ui/core/Divider';
|
||||
import { googleAuthApiRef, githubAuthApiRef } from '@backstage/core-api';
|
||||
import {
|
||||
useApi,
|
||||
googleAuthApiRef,
|
||||
githubAuthApiRef,
|
||||
ProfileInfo,
|
||||
} from '@backstage/core-api';
|
||||
import { Avatar, IconButton, makeStyles, Tooltip } from '@material-ui/core';
|
||||
import PowerButton from '@material-ui/icons/PowerSettingsNew';
|
||||
|
||||
type Provider = {
|
||||
title: string;
|
||||
api: any;
|
||||
identity?: boolean;
|
||||
isSignedIn: boolean;
|
||||
icon: any;
|
||||
};
|
||||
|
||||
const useProviders = () => {
|
||||
const googleAuth = useApi(googleAuthApiRef);
|
||||
const githubAuth = useApi(githubAuthApiRef);
|
||||
const [providers, setProviders] = useState<Provider[]>([
|
||||
{
|
||||
title: 'Google',
|
||||
api: googleAuth,
|
||||
identity: true,
|
||||
isSignedIn: false,
|
||||
icon: Star,
|
||||
},
|
||||
{
|
||||
title: 'Github',
|
||||
api: githubAuth,
|
||||
isSignedIn: false,
|
||||
icon: StarBorder,
|
||||
},
|
||||
]);
|
||||
|
||||
const setIsSignedIn = async () => {
|
||||
const signInChecks = await Promise.all(
|
||||
providers.map(provider =>
|
||||
provider.identity
|
||||
? provider.api.getIdToken({ optional: true })
|
||||
: provider.api.getAccessToken('', { optional: true }),
|
||||
),
|
||||
);
|
||||
|
||||
signInChecks.map((result, i) => {
|
||||
providers[i].isSignedIn = !!result;
|
||||
});
|
||||
|
||||
setProviders(providers);
|
||||
};
|
||||
|
||||
setIsSignedIn();
|
||||
|
||||
return providers;
|
||||
};
|
||||
|
||||
const useStyles = makeStyles({
|
||||
avatar: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
});
|
||||
OAuthProviderSettings,
|
||||
OIDCProviderSettings,
|
||||
UserProfile as SidebarUserProfile,
|
||||
} from './Settings';
|
||||
|
||||
export function SidebarUserSettings() {
|
||||
const { isOpen: sidebarOpen } = useContext(SidebarContext);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const ref = useRef<Element>(); // for scrolling down when collapse item opens
|
||||
const providers = useProviders();
|
||||
const [profile, setProfile] = useState<ProfileInfo>();
|
||||
const classes = useStyles();
|
||||
|
||||
// TODO(soapraj): List all the providers supported by the app and let user log in from here
|
||||
// TODO(soapraj): How to observe if the user is logged in
|
||||
useEffect(() => {
|
||||
const identityProvider = providers.find(
|
||||
(provider: Provider) => provider.identity,
|
||||
);
|
||||
identityProvider?.api
|
||||
.getProfile({ optional: true })
|
||||
.then((userProfile: ProfileInfo) => {
|
||||
setProfile(userProfile);
|
||||
});
|
||||
}, [providers, open]);
|
||||
|
||||
const handleClick = () => {
|
||||
setOpen(!open);
|
||||
setTimeout(() => ref.current?.scrollIntoView({ behavior: 'smooth' }), 300);
|
||||
};
|
||||
|
||||
// Close the provider list when sidebar collapse
|
||||
useEffect(() => {
|
||||
if (!sidebarOpen && open) setOpen(false);
|
||||
}, [open, sidebarOpen]);
|
||||
|
||||
// Handle main auth info that is shown on the collapsible SidebarItem
|
||||
let avatar;
|
||||
let displayName;
|
||||
if (profile) {
|
||||
const email = profile.email;
|
||||
const name = profile.name;
|
||||
const imageUrl = profile.picture;
|
||||
const avatarFallback = email.charAt(0).toUpperCase() + email.slice(1);
|
||||
const emailTrimmed = email.split('@')[0];
|
||||
const displayEmail =
|
||||
emailTrimmed.charAt(0).toUpperCase() + emailTrimmed.slice(1);
|
||||
displayName = name ?? displayEmail;
|
||||
avatar = imageUrl
|
||||
? () => <Avatar alt={name} src={imageUrl} className={classes.avatar} />
|
||||
: () => (
|
||||
<Avatar alt={name} className={classes.avatar}>
|
||||
{avatarFallback[0]}
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider innerRef={ref} />
|
||||
<SidebarItem
|
||||
text={displayName || 'Guest'}
|
||||
onClick={handleClick}
|
||||
icon={avatar || AccountCircleIcon}
|
||||
disableSelected
|
||||
>
|
||||
{open ? <ExpandLess /> : <ExpandMore />}
|
||||
</SidebarItem>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
{providers.map((provider: Provider) => (
|
||||
<SidebarItem
|
||||
key={provider.title}
|
||||
text={provider.title}
|
||||
icon={provider.icon ?? StarBorder}
|
||||
disableSelected
|
||||
>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
provider.isSignedIn
|
||||
? provider.api.logout()
|
||||
: provider.api.getAccessToken()
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
placement="top"
|
||||
arrow
|
||||
title={
|
||||
provider.isSignedIn
|
||||
? `Logout from ${provider.title}`
|
||||
: `Sign in to ${provider.title}`
|
||||
}
|
||||
>
|
||||
<PowerButton
|
||||
color={provider.isSignedIn ? 'secondary' : 'primary'}
|
||||
/>
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</SidebarItem>
|
||||
))}
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -34,3 +34,4 @@ export {
|
||||
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();
|
||||
|
||||
@@ -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,7 +13,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -21,8 +21,12 @@ describe('CatalogClient', () => {
|
||||
it('builds entity search filters properly', async () => {
|
||||
mockFetch.mockResponse('[]');
|
||||
const client = new CatalogClient({ apiOrigin: '', basePath: '' });
|
||||
const entities = await client.getEntities({ a: '1', ö: '=' });
|
||||
const entities = await client.getEntities({
|
||||
a: '1',
|
||||
b: ['2', '3'],
|
||||
ö: '=',
|
||||
});
|
||||
expect(entities).toEqual([]);
|
||||
expect(mockFetch).toBeCalledWith('/entities?a=1&%C3%B6=%3D');
|
||||
expect(mockFetch).toBeCalledWith('/entities?a=1&b=2&b=3&%C3%B6=%3D');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
LOCATION_ANNOTATION,
|
||||
} from '@backstage/catalog-model';
|
||||
import Cache from 'node-cache';
|
||||
import { DescriptorEnvelope } from '../types';
|
||||
import { CatalogApi, EntityCompoundName } from './types';
|
||||
|
||||
export class CatalogClient implements CatalogApi {
|
||||
@@ -78,16 +77,26 @@ export class CatalogClient implements CatalogApi {
|
||||
}
|
||||
|
||||
async getEntities(
|
||||
filter?: Record<string, string>,
|
||||
): Promise<DescriptorEnvelope[]> {
|
||||
const cachedValue = this.cache.get<DescriptorEnvelope[]>(
|
||||
filter?: Record<string, string | string[]>,
|
||||
): Promise<Entity[]> {
|
||||
const cachedValue = this.cache.get<Entity[]>(
|
||||
`get:${JSON.stringify(filter)}`,
|
||||
);
|
||||
if (cachedValue) return cachedValue;
|
||||
|
||||
let path = `/entities`;
|
||||
if (filter) {
|
||||
path += `?${new URLSearchParams(filter).toString()}`;
|
||||
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);
|
||||
@@ -134,4 +143,20 @@ export class CatalogClient implements CatalogApi {
|
||||
.map(r => r.data)
|
||||
.find(l => locationCompound === `${l.type}:${l.target}`);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
@@ -33,9 +34,10 @@ export interface CatalogApi {
|
||||
getEntityByName(
|
||||
compoundName: EntityCompoundName,
|
||||
): Promise<Entity | undefined>;
|
||||
getEntities(filter?: Record<string, string>): Promise<Entity[]>;
|
||||
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;
|
||||
|
||||
@@ -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,7 +14,6 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import {
|
||||
ApiProvider,
|
||||
ApiRegistry,
|
||||
@@ -28,6 +27,7 @@ import React from 'react';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { CatalogApi } from '../../api/types';
|
||||
import { CatalogPage } from './CatalogPage';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
|
||||
describe('CatalogPage', () => {
|
||||
const mockErrorApi = new MockErrorApi();
|
||||
|
||||
@@ -37,7 +37,7 @@ import React, { FC, useCallback, useState } from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { dataResolvers, defaultFilter, filterGroups } from '../../data/filters';
|
||||
import { defaultFilter, entityFilters, filterGroups } from '../../data/filters';
|
||||
import { findLocationForEntityMeta } from '../../data/utils';
|
||||
import { useStarredEntities } from '../../hooks/useStarredEntites';
|
||||
import {
|
||||
@@ -70,10 +70,11 @@ export const CatalogPage: FC<{}> = () => {
|
||||
defaultFilter,
|
||||
);
|
||||
|
||||
const { value, error, loading } = useAsync(
|
||||
() => dataResolvers[selectedFilter.id]({ catalogApi, isStarredEntity }),
|
||||
[selectedFilter.id, starredEntities.size],
|
||||
);
|
||||
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),
|
||||
@@ -182,7 +183,7 @@ export const CatalogPage: FC<{}> = () => {
|
||||
>
|
||||
Create Service
|
||||
</Button>
|
||||
<SupportButton>All your components</SupportButton>
|
||||
<SupportButton>All your software catalog entities</SupportButton>
|
||||
</ContentHeader>
|
||||
<div className={styles.contentWrapper}>
|
||||
<div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { render } from '@testing-library/react';
|
||||
@@ -50,12 +51,12 @@ describe('CatalogTable component', () => {
|
||||
),
|
||||
);
|
||||
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
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 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';
|
||||
@@ -44,8 +45,12 @@ const columns: TableColumn[] = [
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Kind',
|
||||
field: 'kind',
|
||||
title: 'Owner',
|
||||
field: 'spec.owner',
|
||||
},
|
||||
{
|
||||
title: 'Lifecycle',
|
||||
field: 'spec.lifecycle',
|
||||
},
|
||||
{
|
||||
title: 'Description',
|
||||
@@ -72,7 +77,7 @@ export 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>
|
||||
);
|
||||
|
||||
+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();
|
||||
});
|
||||
});
|
||||
+8
-9
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
IconButton,
|
||||
ListItemIcon,
|
||||
@@ -34,13 +35,11 @@ const useStyles = makeStyles({
|
||||
},
|
||||
});
|
||||
|
||||
type ComponentContextMenuProps = {
|
||||
onUnregisterComponent: () => void;
|
||||
type Props = {
|
||||
onUnregisterEntity: () => void;
|
||||
};
|
||||
|
||||
export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
|
||||
onUnregisterComponent,
|
||||
}) => {
|
||||
export const EntityContextMenu: FC<Props> = ({ onUnregisterEntity }) => {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement>();
|
||||
const classes = useStyles();
|
||||
|
||||
@@ -53,7 +52,7 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<IconButton
|
||||
aria-label="more"
|
||||
aria-controls="long-menu"
|
||||
@@ -75,13 +74,13 @@ export 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,6 +90,6 @@ export const ComponentContextMenu: FC<ComponentContextMenuProps> = ({
|
||||
</MenuItem>
|
||||
</MenuList>
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+5
-6
@@ -13,21 +13,20 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { ComponentMetadataCard } from './ComponentMetadataCard';
|
||||
import { EntityMetadataCard } from './EntityMetadataCard';
|
||||
|
||||
describe('ComponentMetadataCard component', () => {
|
||||
it('should display component name if provided', async () => {
|
||||
describe('EntityMetadataCard component', () => {
|
||||
it('should display entity name if provided', async () => {
|
||||
const testEntity: Entity = {
|
||||
apiVersion: 'backstage.io/v1beta1',
|
||||
kind: 'Component',
|
||||
metadata: { name: 'test' },
|
||||
};
|
||||
const rendered = await render(
|
||||
<ComponentMetadataCard entity={testEntity} />,
|
||||
);
|
||||
const rendered = await render(<EntityMetadataCard entity={testEntity} />);
|
||||
expect(await rendered.findByText('test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+2
-1
@@ -13,6 +13,7 @@
|
||||
* 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';
|
||||
@@ -21,7 +22,7 @@ type Props = {
|
||||
entity: Entity;
|
||||
};
|
||||
|
||||
export const ComponentMetadataCard: FC<Props> = ({ entity }) => (
|
||||
export const EntityMetadataCard: FC<Props> = ({ entity }) => (
|
||||
<InfoCard title="Metadata">
|
||||
<StructuredMetadataTable metadata={entity.metadata} />
|
||||
</InfoCard>
|
||||
+8
-7
@@ -13,12 +13,13 @@
|
||||
* 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 = (name: string) => {
|
||||
return {
|
||||
@@ -36,8 +37,8 @@ const getTestProps = (name: 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(
|
||||
@@ -52,7 +53,7 @@ describe('ComponentPage', () => {
|
||||
],
|
||||
])}
|
||||
>
|
||||
<ComponentPage {...props} />
|
||||
<EntityPage {...props} />
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
+12
-16
@@ -31,13 +31,13 @@ import { Alert } from '@material-ui/lab';
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import { useAsync } from 'react-use';
|
||||
import { catalogApiRef } from '../..';
|
||||
import { ComponentContextMenu } from '../ComponentContextMenu/ComponentContextMenu';
|
||||
import { ComponentMetadataCard } from '../ComponentMetadataCard/ComponentMetadataCard';
|
||||
import { ComponentRemovalDialog } from '../ComponentRemovalDialog/ComponentRemovalDialog';
|
||||
import { EntityContextMenu } from '../EntityContextMenu/EntityContextMenu';
|
||||
import { EntityMetadataCard } from '../EntityMetadataCard/EntityMetadataCard';
|
||||
import { UnregisterEntityDialog } from '../UnregisterEntityDialog/UnregisterEntityDialog';
|
||||
|
||||
const REDIRECT_DELAY = 1000;
|
||||
|
||||
type ComponentPageProps = {
|
||||
type Props = {
|
||||
match: {
|
||||
params: {
|
||||
optionalNamespaceAndName: string;
|
||||
@@ -68,7 +68,7 @@ function headerProps(
|
||||
};
|
||||
}
|
||||
|
||||
export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
export const EntityPage: FC<Props> = ({ match, history }) => {
|
||||
const { optionalNamespaceAndName, kind } = match.params;
|
||||
const [name, namespace] = optionalNamespaceAndName.split(':').reverse();
|
||||
|
||||
@@ -83,7 +83,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!error && !loading && !entity) {
|
||||
errorApi.post(new Error('Component not found!'));
|
||||
errorApi.post(new Error('Entity not found!'));
|
||||
setTimeout(() => {
|
||||
history.push('/');
|
||||
}, REDIRECT_DELAY);
|
||||
@@ -95,14 +95,12 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const removeComponent = async () => {
|
||||
const cleanUpAfterRemoval = async () => {
|
||||
setConfirmationDialogOpen(false);
|
||||
// await componentFactory.removeComponentByName(componentName);
|
||||
history.push('/');
|
||||
};
|
||||
|
||||
const showRemovalDialog = () => setConfirmationDialogOpen(true);
|
||||
const hideRemovalDialog = () => setConfirmationDialogOpen(false);
|
||||
|
||||
// TODO - Replace with proper tabs implementation
|
||||
const tabs = [
|
||||
@@ -143,9 +141,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
// TODO: Switch theme and type props based on component type (website, library, ...)
|
||||
<Page theme={pageTheme.service}>
|
||||
<Header title={headerTitle} type={headerType}>
|
||||
{entity && (
|
||||
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
|
||||
)}
|
||||
{entity && <EntityContextMenu onUnregisterEntity={showRemovalDialog} />}
|
||||
</Header>
|
||||
|
||||
{loading && <Progress />}
|
||||
@@ -163,7 +159,7 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
<Content>
|
||||
<Grid container spacing={3} direction="column">
|
||||
<Grid item>
|
||||
<ComponentMetadataCard entity={entity} />
|
||||
<EntityMetadataCard entity={entity} />
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<SentryIssuesWidget
|
||||
@@ -174,11 +170,11 @@ export const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
|
||||
</Grid>
|
||||
</Content>
|
||||
|
||||
<ComponentRemovalDialog
|
||||
<UnregisterEntityDialog
|
||||
open={confirmationDialogOpen}
|
||||
entity={entity}
|
||||
onClose={hideRemovalDialog}
|
||||
onConfirm={removeComponent}
|
||||
onConfirm={cleanUpAfterRemoval}
|
||||
onClose={() => setConfirmationDialogOpen(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
+23
-8
@@ -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,
|
||||
@@ -33,7 +33,7 @@ import { useAsync } from 'react-use';
|
||||
import { AsyncState } from 'react-use/lib/useAsync';
|
||||
import { catalogApiRef } from '../../api/types';
|
||||
|
||||
type ComponentRemovalDialogProps = {
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onConfirm: () => any;
|
||||
onClose: () => any;
|
||||
@@ -50,7 +50,7 @@ function useColocatedEntities(entity: Entity): AsyncState<Entity[]> {
|
||||
}, [catalogApi, entity]);
|
||||
}
|
||||
|
||||
export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
|
||||
export const UnregisterEntityDialog: FC<Props> = ({
|
||||
open,
|
||||
onConfirm,
|
||||
onClose,
|
||||
@@ -59,11 +59,24 @@ export const ComponentRemovalDialog: FC<ComponentRemovalDialogProps> = ({
|
||||
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={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}
|
||||
@@ -90,21 +103,23 @@ export 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={onClose}>Cancel</Button>
|
||||
<Button onClick={onClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!!(loading || error)}
|
||||
onClick={onConfirm}
|
||||
onClick={removeEntity}
|
||||
color="secondary"
|
||||
>
|
||||
Unregister
|
||||
@@ -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,24 +61,16 @@ export const filterGroups: CatalogFilterGroup[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type ResolverFunction = ({
|
||||
catalogApi,
|
||||
isStarredEntity,
|
||||
}: {
|
||||
catalogApi: CatalogApi;
|
||||
isStarredEntity: (entity: Entity) => boolean;
|
||||
}) => 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, isStarredEntity }) => {
|
||||
const allEntities = await catalogApi.getEntities();
|
||||
type EntityFilterOptions = {
|
||||
isStarred: boolean;
|
||||
};
|
||||
|
||||
return allEntities.filter(entity => isStarredEntity(entity));
|
||||
},
|
||||
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,23 +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,
|
||||
namespace: envelope.metadata.namespace,
|
||||
kind: envelope.kind,
|
||||
metadata: envelope.metadata,
|
||||
description: envelope.metadata.annotations?.description ?? 'placeholder',
|
||||
};
|
||||
}
|
||||
|
||||
export function findLocationForEntityMeta(
|
||||
meta: EntityMeta,
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useApi, storageApiRef } from '@backstage/core';
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
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'}:${
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { renderHook, act } from '@testing-library/react-hooks';
|
||||
import { useStarredEntities } from './useStarredEntites';
|
||||
|
||||
@@ -17,5 +17,4 @@
|
||||
export { plugin } from './plugin';
|
||||
export * from './api/CatalogClient';
|
||||
export * from './api/types';
|
||||
export * from './types';
|
||||
export * from './routes';
|
||||
|
||||
@@ -16,13 +16,13 @@
|
||||
|
||||
import { createPlugin } from '@backstage/core';
|
||||
import { CatalogPage } from './components/CatalogPage/CatalogPage';
|
||||
import { ComponentPage } from './components/ComponentPage/ComponentPage';
|
||||
import { EntityPage } from './components/EntityPage/EntityPage';
|
||||
import { entityRoute, rootRoute } from './routes';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'catalog',
|
||||
register({ router }) {
|
||||
router.addRoute(rootRoute, CatalogPage);
|
||||
router.addRoute(entityRoute, ComponentPage);
|
||||
router.addRoute(entityRoute, EntityPage);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,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.
|
||||
@@ -13,13 +13,8 @@
|
||||
* 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;
|
||||
namespace?: string;
|
||||
kind: string;
|
||||
metadata: EntityMeta;
|
||||
description: ReactNode;
|
||||
};
|
||||
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;
|
||||
@@ -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 { default } from './ClusterList';
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* Copyright 2020 Spotify AB
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { FC, useEffect, useState } from 'react';
|
||||
import {
|
||||
Content,
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Table,
|
||||
Progress,
|
||||
HeaderLabel,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
|
||||
import { Link } from '@material-ui/core';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { gitOpsApiRef, Status } from '../../api';
|
||||
import { transformRunStatus } from '../ProfileCatalog';
|
||||
|
||||
const ClusterPage: FC<{}> = () => {
|
||||
const params = useParams<{ owner: string; repo: string }>();
|
||||
|
||||
const [loginInfo] = useLocalStorage<{
|
||||
token: string;
|
||||
username: string;
|
||||
name: string;
|
||||
}>('githubLoginDetails');
|
||||
|
||||
const [pollingLog, setPollingLog] = useState(true);
|
||||
const [runStatus, setRunStatus] = useState<Status[]>([]);
|
||||
const [runLink, setRunLink] = useState<string>('');
|
||||
const [showProgress, setShowProgress] = useState(true);
|
||||
|
||||
const api = useApi(gitOpsApiRef);
|
||||
|
||||
const columns = [
|
||||
{ field: 'status', title: 'Status' },
|
||||
{ field: 'message', title: 'Message' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: params.owner,
|
||||
targetRepo: params.repo,
|
||||
});
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
return () => {};
|
||||
}, [pollingLog, api, loginInfo, params]);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title={`Cluster ${params.owner}/${params.repo}`}>
|
||||
<HeaderLabel label="Welcome" value={loginInfo.name} />
|
||||
</Header>
|
||||
<Content>
|
||||
<Progress hidden={!showProgress} />
|
||||
<Table
|
||||
options={{ search: false, paging: false, toolbar: false }}
|
||||
data={transformRunStatus(runStatus)}
|
||||
columns={columns}
|
||||
/>
|
||||
<Link
|
||||
hidden={runLink === ''}
|
||||
rel="noopener noreferrer"
|
||||
href={`${runLink}?check_suite_focus=true`}
|
||||
target="_blank"
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterPage;
|
||||
@@ -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 { default } from './ClusterPage';
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* 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 { Table, TableColumn } from '@backstage/core';
|
||||
import { Link } from '@material-ui/core';
|
||||
import { ClusterStatus } from '../../api';
|
||||
import { transformStatus } from '../ProfileCatalog/ProfileCatalog';
|
||||
|
||||
const columns: TableColumn[] = [
|
||||
{
|
||||
title: 'Cluster Name',
|
||||
field: 'name',
|
||||
highlight: true,
|
||||
render: (componentData: any) => (
|
||||
<Link href={`/gitops-cluster/${componentData.name}`}>
|
||||
{componentData.name}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
field: 'status',
|
||||
render: (componentData: any) => (
|
||||
<>
|
||||
{transformStatus({
|
||||
status: componentData.status,
|
||||
conclusion: componentData.conclusion,
|
||||
message: componentData.status,
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Conclusion',
|
||||
field: 'Conclusion',
|
||||
render: (componentData: any) => (
|
||||
<>
|
||||
{transformStatus({
|
||||
status: componentData.status,
|
||||
conclusion: componentData.conclusion,
|
||||
message: componentData.conclusion,
|
||||
})}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
type ClusterTableProps = {
|
||||
components: ClusterStatus[];
|
||||
};
|
||||
const ClusterTable: FC<ClusterTableProps> = ({ components }) => {
|
||||
return (
|
||||
<Table columns={columns} options={{ paging: false }} data={components} />
|
||||
);
|
||||
};
|
||||
export default ClusterTable;
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* 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 { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
|
||||
import Card from '@material-ui/core/Card';
|
||||
import CardHeader from '@material-ui/core/CardHeader';
|
||||
import CardContent from '@material-ui/core/CardContent';
|
||||
import CardActions from '@material-ui/core/CardActions';
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import Typography from '@material-ui/core/Typography';
|
||||
import { red } from '@material-ui/core/colors';
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
maxWidth: 345,
|
||||
},
|
||||
media: {
|
||||
height: 0,
|
||||
paddingTop: '56.25%', // 16:9
|
||||
},
|
||||
expand: {
|
||||
transform: 'rotate(0deg)',
|
||||
marginLeft: 'auto',
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
expandOpen: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
avatar: {
|
||||
fontSize: '1.0rem',
|
||||
backgroundColor: red[500],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
platformName: string;
|
||||
title: string;
|
||||
repository: string;
|
||||
description: string;
|
||||
index: number;
|
||||
onClick: (i: number, repo: string) => void;
|
||||
activeIndex: number;
|
||||
}
|
||||
|
||||
const ClusterTemplateCard: FC<Props> = props => {
|
||||
const classes = useStyles();
|
||||
|
||||
const handleSelect = () => {
|
||||
props.onClick(props.index, props.repository);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar aria-label="recipe" className={classes.avatar}>
|
||||
{props.platformName}
|
||||
</Avatar>
|
||||
}
|
||||
action={<IconButton aria-label="settings" />}
|
||||
title={props.title}
|
||||
subheader={props.repository}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="textSecondary" component="p">
|
||||
{props.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions disableSpacing>
|
||||
<IconButton aria-label="select" onClick={handleSelect}>
|
||||
{props.activeIndex === props.index ? (
|
||||
<CheckBoxIcon color="primary" />
|
||||
) : (
|
||||
<CheckBoxOutlineBlankIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterTemplateCard;
|
||||
@@ -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 { default } from './ClusterTemplateCard';
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { Grid } from '@material-ui/core';
|
||||
import ClusterTemplateCard from '../ClusterTemplateCard';
|
||||
|
||||
interface Props {
|
||||
template: {
|
||||
platformName: string;
|
||||
title: string;
|
||||
repository: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const ClusterTemplateCardList: FC<Props> = props => {
|
||||
const [activeIndex, setActiveIndex] = React.useState(-1);
|
||||
|
||||
const handleClicked = (index: number, repository: string) => {
|
||||
setActiveIndex(index);
|
||||
window.localStorage.setItem('gitops-template-repo', repository);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container xl={12} spacing={4}>
|
||||
{props.template.map((value, index) => (
|
||||
<Grid item xl={2} key={index}>
|
||||
<ClusterTemplateCard
|
||||
activeIndex={activeIndex}
|
||||
onClick={handleClicked}
|
||||
index={index}
|
||||
key={index}
|
||||
platformName={value.platformName}
|
||||
title={value.title}
|
||||
repository={value.repository}
|
||||
description={value.description}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterTemplateCardList;
|
||||
@@ -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 { default } from './ClusterTemplateCardList';
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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 } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Card,
|
||||
CardActions,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
createStyles,
|
||||
IconButton,
|
||||
Theme,
|
||||
Typography,
|
||||
} from '@material-ui/core';
|
||||
import { green } from '@material-ui/core/colors';
|
||||
import { makeStyles } from '@material-ui/core/styles';
|
||||
import CheckBoxOutlineBlankIcon from '@material-ui/icons/CheckBoxOutlineBlank';
|
||||
import CheckBoxIcon from '@material-ui/icons/CheckBox';
|
||||
|
||||
const useStyles = makeStyles((theme: Theme) =>
|
||||
createStyles({
|
||||
root: {
|
||||
maxWidth: 345,
|
||||
},
|
||||
media: {
|
||||
height: 0,
|
||||
paddingTop: '56.25%', // 16:9
|
||||
},
|
||||
expand: {
|
||||
transform: 'rotate(0deg)',
|
||||
marginLeft: 'auto',
|
||||
transition: theme.transitions.create('transform', {
|
||||
duration: theme.transitions.duration.shortest,
|
||||
}),
|
||||
},
|
||||
expandOpen: {
|
||||
transform: 'rotate(180deg)',
|
||||
},
|
||||
avatar: {
|
||||
backgroundColor: green[500],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
shortName: string;
|
||||
title: string;
|
||||
repository: string;
|
||||
description: string;
|
||||
index: number;
|
||||
onClick: (i: number, repository: string) => void;
|
||||
selections: Set<number>;
|
||||
}
|
||||
|
||||
const ProfileCard: FC<Props> = props => {
|
||||
const [selection, setSelection] = useState(false);
|
||||
|
||||
const handleSelect = () => {
|
||||
props.onClick(props.index, props.repository);
|
||||
setSelection(props.selections.has(props.index));
|
||||
};
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
return (
|
||||
<Card className={classes.root}>
|
||||
<CardHeader
|
||||
avatar={
|
||||
<Avatar aria-label="recipe" className={classes.avatar}>
|
||||
{props.shortName}
|
||||
</Avatar>
|
||||
}
|
||||
action={<IconButton aria-label="settings" />}
|
||||
title={props.title}
|
||||
subheader={props.repository.replace('https://github.com/', '')}
|
||||
/>
|
||||
<CardContent>
|
||||
<Typography variant="body2" color="textSecondary" component="p">
|
||||
{props.description}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
<CardActions disableSpacing>
|
||||
<IconButton aria-label="select" onClick={handleSelect}>
|
||||
{selection ? (
|
||||
<CheckBoxIcon color="primary" />
|
||||
) : (
|
||||
<CheckBoxOutlineBlankIcon />
|
||||
)}
|
||||
</IconButton>
|
||||
</CardActions>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCard;
|
||||
@@ -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 { default } from './ProfileCard';
|
||||
@@ -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, { FC, useState } from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import ProfileCard from '../ProfileCard';
|
||||
|
||||
interface Props {
|
||||
profileTemplates: {
|
||||
shortName: string;
|
||||
title: string;
|
||||
repository: string;
|
||||
description: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
const ProfileCardList: FC<Props> = props => {
|
||||
const [selections, setSelections] = useState<Set<number>>(new Set<number>());
|
||||
const [profiles, setProfiles] = useState<Set<string>>(new Set<string>());
|
||||
|
||||
const handleClicked = (index: number, repository: string) => {
|
||||
if (selections.has(index)) {
|
||||
selections.delete(index);
|
||||
profiles.delete(repository);
|
||||
} else {
|
||||
selections.add(index);
|
||||
profiles.add(repository);
|
||||
}
|
||||
|
||||
setSelections(selections);
|
||||
setProfiles(profiles);
|
||||
|
||||
window.localStorage.setItem(
|
||||
'gitops-profiles',
|
||||
JSON.stringify(Array.from(profiles)),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Grid container xl={12} spacing={4}>
|
||||
{props.profileTemplates.map((value, index) => (
|
||||
<Grid item xl={2} key={index}>
|
||||
<ProfileCard
|
||||
shortName={value.shortName}
|
||||
selections={selections}
|
||||
onClick={handleClicked}
|
||||
key={index}
|
||||
index={index}
|
||||
title={value.title}
|
||||
repository={value.repository}
|
||||
description={value.description}
|
||||
/>
|
||||
</Grid>
|
||||
))}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCardList;
|
||||
@@ -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 { default } from './ProfileCardList';
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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 mockFetch from 'jest-fetch-mock';
|
||||
import ProfileCatalog from './ProfileCatalog';
|
||||
import { ThemeProvider } from '@material-ui/core';
|
||||
import { lightTheme } from '@backstage/theme';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-api';
|
||||
import { gitOpsApiRef, GitOpsRestApi } from '../../api';
|
||||
|
||||
describe('ProfileCatalog', () => {
|
||||
it('should render', () => {
|
||||
const apis = ApiRegistry.from([
|
||||
[gitOpsApiRef, new GitOpsRestApi('http://localhost:3008')],
|
||||
]);
|
||||
mockFetch.mockResponse(() => new Promise(() => {}));
|
||||
const rendered = render(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<ApiProvider apis={apis}>
|
||||
<ProfileCatalog />
|
||||
</ApiProvider>
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(
|
||||
rendered.getByText('Create GitOps-managed Cluster'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* 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 {
|
||||
Header,
|
||||
Page,
|
||||
pageTheme,
|
||||
Content,
|
||||
ContentHeader,
|
||||
HeaderLabel,
|
||||
SupportButton,
|
||||
SimpleStepper,
|
||||
SimpleStepperStep,
|
||||
InfoCard,
|
||||
Progress,
|
||||
Table,
|
||||
StatusWarning,
|
||||
StatusOK,
|
||||
StatusRunning,
|
||||
StatusError,
|
||||
StatusPending,
|
||||
StatusAborted,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import { TextField, List, ListItem, Link } from '@material-ui/core';
|
||||
|
||||
import ClusterTemplateCardList from '../ClusterTemplateCardList';
|
||||
import ProfileCardList from '../ProfileCardList';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { gitOpsApiRef, Status } from '../../api';
|
||||
|
||||
// OK = (completed, success)
|
||||
// Error = (?,failure)
|
||||
// Aborted = (?,cancelled)
|
||||
// Error = (?,timed_out)
|
||||
// Warning = (?, skipped)
|
||||
// Running = (queued, ?)
|
||||
// Running = (in_progress,?)
|
||||
export const transformStatus = (value: Status): JSX.Element => {
|
||||
let status: JSX.Element = <StatusRunning>Unknown</StatusRunning>;
|
||||
if (value.status === 'completed' && value.conclusion === 'success') {
|
||||
status = <StatusOK>Success</StatusOK>;
|
||||
} else if (value.conclusion === 'failure') {
|
||||
status = <StatusError>Failure</StatusError>;
|
||||
} else if (value.conclusion === 'cancelled') {
|
||||
status = <StatusAborted>Cancelled</StatusAborted>;
|
||||
} else if (value.conclusion === 'timed_out') {
|
||||
status = <StatusError>Timed out</StatusError>;
|
||||
} else if (value.conclusion === 'skipped') {
|
||||
status = <StatusWarning>Skipped</StatusWarning>;
|
||||
} else if (value.status === 'queued') {
|
||||
status = <StatusPending>Queued</StatusPending>;
|
||||
} else if (value.status === 'in_progress') {
|
||||
status = <StatusRunning>In Progress</StatusRunning>;
|
||||
}
|
||||
return status;
|
||||
};
|
||||
|
||||
export const transformRunStatus = (x: Status[]) => {
|
||||
return x.map(value => {
|
||||
return {
|
||||
status: transformStatus(value),
|
||||
message: value.message,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const ProfileCatalog: FC<{}> = () => {
|
||||
// TODO: get data from REST API
|
||||
const [clusterTemplates] = React.useState([
|
||||
{
|
||||
platformName: '15m',
|
||||
title: 'EKS 2 workers',
|
||||
repository: 'chanwit/eks-cluster-template',
|
||||
description: 'EKS with Kubernetes 1.16 / 2 nodes of m5.xlarge (15 mins)',
|
||||
},
|
||||
{
|
||||
platformName: '15m',
|
||||
title: 'EKS 1 worker',
|
||||
repository: 'chanwit/template-2',
|
||||
description: 'EKS with Kubernetes 1.16 / 1 node of m5.xlarge (15 mins)',
|
||||
},
|
||||
]);
|
||||
|
||||
const [profileTemplates] = React.useState([
|
||||
{
|
||||
shortName: 'ml',
|
||||
title: 'MLOps',
|
||||
repository: 'https://github.com/weaveworks/mlops-profile',
|
||||
description: 'Kubeflow-based Machine Learning pipeline',
|
||||
},
|
||||
{
|
||||
shortName: 'ai',
|
||||
title: 'COVID ML',
|
||||
repository: 'https://github.com/weaveworks/covid-ml-profile',
|
||||
description: 'Fk-covid Application profile',
|
||||
},
|
||||
]);
|
||||
|
||||
const [loginInfo] = useLocalStorage('githubLoginDetails', {
|
||||
name: 'Guest',
|
||||
username: '',
|
||||
token: '',
|
||||
});
|
||||
const [templateRepo] = useLocalStorage<string>('gitops-template-repo');
|
||||
const [gitopsProfiles] = useLocalStorage<string[]>('gitops-profiles');
|
||||
|
||||
const [showProgress, setShowProgress] = useState(false);
|
||||
const [pollingLog, setPollingLog] = useState(false);
|
||||
const [gitHubOrg, setGitHubOrg] = useState(loginInfo.username);
|
||||
const [gitHubRepo, setGitHubRepo] = useState('new-cluster');
|
||||
const [awsAccessKeyId, setAwsAccessKeyId] = useState(String);
|
||||
const [awsSecretAccessKey, setAwsSecretAccessKey] = useState(String);
|
||||
const [runStatus, setRunStatus] = useState<Status[]>([]);
|
||||
const [runLink, setRunLink] = useState<string>('');
|
||||
|
||||
const api = useApi(gitOpsApiRef);
|
||||
|
||||
useEffect(() => {
|
||||
if (pollingLog) {
|
||||
const interval = setInterval(async () => {
|
||||
const resp = await api.fetchLog({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
});
|
||||
|
||||
setRunStatus(resp.result);
|
||||
setRunLink(resp.link);
|
||||
if (resp.status === 'completed') {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
}
|
||||
}, 10000);
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
return () => {};
|
||||
}, [pollingLog, api, gitHubOrg, gitHubRepo, loginInfo]);
|
||||
|
||||
const showFailureMessage = (msg: string) => {
|
||||
setRunStatus(
|
||||
runStatus.concat([
|
||||
{
|
||||
status: 'completed',
|
||||
message: msg,
|
||||
conclusion: 'failure',
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const showSuccessMessage = (msg: string) => {
|
||||
setRunStatus(
|
||||
runStatus.concat([
|
||||
{
|
||||
status: 'completed',
|
||||
message: msg,
|
||||
conclusion: 'success',
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
const doCreateCluster = async () => {
|
||||
setShowProgress(true);
|
||||
setRunStatus([]);
|
||||
|
||||
const cloneResponse = await api.cloneClusterFromTemplate({
|
||||
templateRepository: templateRepo,
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
secrets: {
|
||||
awsAccessKeyId: awsAccessKeyId,
|
||||
awsSecretAccessKey: awsSecretAccessKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (cloneResponse.error === undefined) {
|
||||
showSuccessMessage('Forked new cluster repo');
|
||||
} else {
|
||||
setShowProgress(false);
|
||||
showFailureMessage(cloneResponse.error);
|
||||
}
|
||||
|
||||
const applyProfileResp = await api.applyProfiles({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
profiles: gitopsProfiles,
|
||||
});
|
||||
|
||||
if (applyProfileResp.error === undefined) {
|
||||
showSuccessMessage('Applied profiles to the repo');
|
||||
} else {
|
||||
setShowProgress(false);
|
||||
showFailureMessage(applyProfileResp.error);
|
||||
}
|
||||
|
||||
const clusterStateResp = await api.changeClusterState({
|
||||
gitHubToken: loginInfo.token,
|
||||
gitHubUser: loginInfo.username,
|
||||
targetOrg: gitHubOrg,
|
||||
targetRepo: gitHubRepo,
|
||||
clusterState: 'present',
|
||||
});
|
||||
|
||||
if (clusterStateResp.error === undefined) {
|
||||
// cluster creation start, so start pulling log
|
||||
setPollingLog(true);
|
||||
showSuccessMessage('Changed desired cluster state to present');
|
||||
} else {
|
||||
setPollingLog(false);
|
||||
setShowProgress(false);
|
||||
showFailureMessage(clusterStateResp.error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ field: 'status', title: 'Status' },
|
||||
{ field: 'message', title: 'Message' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header
|
||||
title="Create GitOps-managed Cluster"
|
||||
subtitle="Kubernetes cluster with ready-to-use profiles"
|
||||
>
|
||||
<HeaderLabel label="Welcome" value={loginInfo.name} />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title="Create Cluster">
|
||||
<SupportButton>A description of your plugin goes here.</SupportButton>
|
||||
</ContentHeader>
|
||||
<SimpleStepper>
|
||||
<SimpleStepperStep title="Choose Cluster Template">
|
||||
<ClusterTemplateCardList template={clusterTemplates} />
|
||||
</SimpleStepperStep>
|
||||
<SimpleStepperStep title="Select GitOps Profile">
|
||||
<ProfileCardList profileTemplates={profileTemplates} />
|
||||
</SimpleStepperStep>
|
||||
<SimpleStepperStep
|
||||
title="Create Cluster"
|
||||
actions={{ nextText: 'Create', onNext: () => doCreateCluster() }}
|
||||
>
|
||||
<InfoCard>
|
||||
<List>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-org-tf"
|
||||
label="GitHub Organization"
|
||||
defaultValue={gitHubOrg}
|
||||
required
|
||||
onChange={e => {
|
||||
setGitHubOrg(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="github-repo-tf"
|
||||
label="New Repository"
|
||||
defaultValue={gitHubRepo}
|
||||
required
|
||||
onChange={e => {
|
||||
setGitHubRepo(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="aws-access-key-id-tf"
|
||||
label="Access Key ID"
|
||||
required
|
||||
type="password"
|
||||
onChange={e => {
|
||||
setAwsAccessKeyId(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<TextField
|
||||
name="aws-secret-access-key-tf"
|
||||
label="Secret Access Key"
|
||||
required
|
||||
type="password"
|
||||
onChange={e => {
|
||||
setAwsSecretAccessKey(e.target.value);
|
||||
}}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
</InfoCard>
|
||||
</SimpleStepperStep>
|
||||
</SimpleStepper>
|
||||
<div>
|
||||
<Progress hidden={!showProgress} />
|
||||
<Table
|
||||
options={{ search: false, paging: false, toolbar: false }}
|
||||
data={transformRunStatus(runStatus)}
|
||||
columns={columns}
|
||||
/>
|
||||
<Link
|
||||
hidden={runLink === ''}
|
||||
rel="noopener noreferrer"
|
||||
href={`${runLink}?check_suite_focus=true`}
|
||||
target="_blank"
|
||||
>
|
||||
Details
|
||||
</Link>
|
||||
</div>
|
||||
</Content>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileCatalog;
|
||||
@@ -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 { default, transformRunStatus } from './ProfileCatalog';
|
||||
@@ -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 { plugin } from './plugin';
|
||||
export * from './api';
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* 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 { plugin } from './plugin';
|
||||
|
||||
describe('gitops-profiles', () => {
|
||||
it('should export plugin', () => {
|
||||
expect(plugin).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -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 { createPlugin } from '@backstage/core';
|
||||
import ProfileCatalog from './components/ProfileCatalog';
|
||||
import ClusterPage from './components/ClusterPage';
|
||||
import ClusterList from './components/ClusterList';
|
||||
|
||||
export const plugin = createPlugin({
|
||||
id: 'gitops-profiles',
|
||||
register({ router }) {
|
||||
router.registerRoute('/gitops-clusters', ClusterList);
|
||||
router.registerRoute('/gitops-cluster/:owner/:repo', ClusterPage);
|
||||
router.registerRoute('/gitops-cluster-create', ProfileCatalog);
|
||||
},
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
+2
-1
@@ -29,9 +29,10 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
|
||||
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
|
||||
addLocation: jest.fn((_a, _b) => new Promise(() => {})),
|
||||
getEntities: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
getLocationByEntity: jest.fn(),
|
||||
getLocationById: jest.fn(),
|
||||
removeEntityByUid: jest.fn(),
|
||||
getEntityByName: jest.fn(),
|
||||
};
|
||||
|
||||
const setup = () => ({
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface TechRadarComponentProps {
|
||||
export interface TechRadarApi extends TechRadarComponentProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
pageTitle?: string;
|
||||
}
|
||||
|
||||
export const techRadarApiRef = createApiRef<TechRadarApi>({
|
||||
@@ -84,11 +85,13 @@ export class TechRadar implements TechRadarApi {
|
||||
public svgProps: TechRadarApi['svgProps'];
|
||||
public title: TechRadarApi['title'];
|
||||
public subtitle: TechRadarApi['subtitle'];
|
||||
public pageTitle: TechRadarApi['pageTitle'];
|
||||
|
||||
constructor(overrideOptions: TechRadarApi) {
|
||||
const defaultOptions: Partial<TechRadarApi> = {
|
||||
title: 'Tech Radar',
|
||||
subtitle: 'Welcome to the Tech Radar!',
|
||||
subtitle: 'Pick the recommended technologies for your projects',
|
||||
pageTitle: 'Company Radar',
|
||||
};
|
||||
|
||||
const options = { ...defaultOptions, ...overrideOptions };
|
||||
@@ -99,5 +102,6 @@ export class TechRadar implements TechRadarApi {
|
||||
this.svgProps = options.svgProps;
|
||||
this.title = options.title;
|
||||
this.subtitle = options.subtitle;
|
||||
this.pageTitle = options.pageTitle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +83,9 @@ describe('RadarPage', () => {
|
||||
|
||||
await waitForElement(() => getByTestId('tech-radar-svg'));
|
||||
|
||||
expect(getByText('Welcome to the Tech Radar!')).toBeInTheDocument();
|
||||
expect(
|
||||
getByText('Pick the recommended technologies for your projects'),
|
||||
).toBeInTheDocument();
|
||||
expect(getByTestId('tech-radar-svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,16 @@
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import { Grid } from '@material-ui/core';
|
||||
import { Page, Header, Content, pageTheme, useApi } from '@backstage/core';
|
||||
import {
|
||||
Content,
|
||||
ContentHeader,
|
||||
Page,
|
||||
Header,
|
||||
HeaderLabel,
|
||||
SupportButton,
|
||||
pageTheme,
|
||||
useApi,
|
||||
} from '@backstage/core';
|
||||
import RadarComponent from '../components/RadarComponent';
|
||||
import { techRadarApiRef, TechRadarApi } from '../api';
|
||||
|
||||
@@ -24,9 +33,19 @@ const RadarPage: FC<{}> = () => {
|
||||
const techRadarApi = useApi<TechRadarApi>(techRadarApiRef);
|
||||
|
||||
return (
|
||||
<Page theme={pageTheme.home}>
|
||||
<Header title={techRadarApi.title} subtitle={techRadarApi.subtitle} />
|
||||
<Page theme={pageTheme.tool}>
|
||||
<Header title={techRadarApi.title} subtitle={techRadarApi.subtitle}>
|
||||
<HeaderLabel label="Owner" value="Spotify" />
|
||||
<HeaderLabel label="Lifecycle" value="Beta" />
|
||||
</Header>
|
||||
<Content>
|
||||
<ContentHeader title={techRadarApi.pageTitle}>
|
||||
<SupportButton>
|
||||
This is used for visualizing the official guidelines of different
|
||||
areas of software development such as languages, frameworks,
|
||||
infrastructure and processes.
|
||||
</SupportButton>
|
||||
</ContentHeader>
|
||||
<Grid container spacing={3} direction="row">
|
||||
<Grid item xs={12} sm={6} md={4}>
|
||||
<RadarComponent {...techRadarApi} />
|
||||
|
||||
@@ -88,6 +88,24 @@ entries.push({
|
||||
quadrant: { id: 'process', name: 'Process' },
|
||||
ring: { id: 'assess', name: 'ASSESS', color: '#fbdb84' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
url: '#',
|
||||
key: 'docs-like-code',
|
||||
id: 'docs-like-code',
|
||||
title: 'Docs-like-code',
|
||||
quadrant: { id: 'process', name: 'Process' },
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
url: '#',
|
||||
key: 'force-push',
|
||||
id: 'force-push',
|
||||
title: 'Force push to master',
|
||||
quadrant: { id: 'process', name: 'Process' },
|
||||
ring: { id: 'hold', name: 'HOLD', color: '#93c47d' },
|
||||
});
|
||||
entries.push({
|
||||
moved: 0,
|
||||
ring: { id: 'use', name: 'USE', color: '#93c47d' },
|
||||
|
||||
Reference in New Issue
Block a user