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:
blam
2020-06-11 17:36:37 +02:00
92 changed files with 2249 additions and 534 deletions
+1
View File
@@ -8,6 +8,7 @@
"@backstage/plugin-catalog": "^0.1.1-alpha.7",
"@backstage/plugin-circleci": "^0.1.1-alpha.7",
"@backstage/plugin-explore": "^0.1.1-alpha.7",
"@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7",
"@backstage/plugin-home-page": "^0.1.1-alpha.7",
"@backstage/plugin-lighthouse": "^0.1.1-alpha.7",
"@backstage/plugin-register-component": "^0.1.1-alpha.7",
+4
View File
@@ -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 />
+1
View File
@@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore';
export { plugin as Circleci } from '@backstage/plugin-circleci';
export { plugin as RegisterComponent } from '@backstage/plugin-register-component';
export { plugin as Sentry } from '@backstage/plugin-sentry';
export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles';
+4 -9
View File
@@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location
to the backend. These are places where the backend can find some entity descriptor
data to consume and serve.
To get started, you can issue the following after starting the backend:
To get started, you can issue the following after starting the backend, from inside
the `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.
+11 -2
View File
@@ -15,6 +15,7 @@
*/
import { createApiRef } from '../ApiRef';
import { Observable } from '../..';
/**
* This file contains declarations for common interfaces of auth-related APIs.
@@ -167,6 +168,14 @@ export type ProfileInfo = {
picture?: string;
};
export enum SessionState {
SignedIn = 'SignedIn',
SignedOut = 'SignedOut',
}
export type SessionStateApi = {
sessionState$(): Observable<SessionState>;
};
/**
* Provides authentication towards Google APIs and identities.
*
@@ -176,7 +185,7 @@ export type ProfileInfo = {
* email and expiration information. Do not rely on any other fields, as they might not be present.
*/
export const googleAuthApiRef = createApiRef<
OAuthApi & OpenIdConnectApi & ProfileInfoApi
OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi
>({
id: 'core.auth.google',
description: 'Provides authentication towards Google APIs and identities',
@@ -188,7 +197,7 @@ export const googleAuthApiRef = createApiRef<
* See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/
* for a full list of supported scopes.
*/
export const githubAuthApiRef = createApiRef<OAuthApi>({
export const githubAuthApiRef = createApiRef<OAuthApi & SessionStateApi>({
id: 'core.auth.github',
description: 'Provides authentication towards Github APIs',
});
@@ -17,10 +17,17 @@
import GithubIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GithubSession } from './types';
import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth';
import {
OAuthApi,
AccessTokenOptions,
SessionStateApi,
SessionState,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth
@@ -46,7 +53,7 @@ const DEFAULT_PROVIDER = {
icon: GithubIcon,
};
class GithubAuth implements OAuthApi {
class GithubAuth implements OAuthApi, SessionStateApi {
static create({
apiOrigin,
basePath,
@@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi {
return new GithubAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GithubSession>) {}
async getAccessToken(scope?: string, options?: AccessTokenOptions) {
@@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi {
...options,
scopes: normalizedScopes,
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
@@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi {
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
static normalizeScope(scope?: string): Set<string> {
@@ -25,10 +25,14 @@ import {
ProfileInfoApi,
ProfileInfoOptions,
ProfileInfo,
SessionStateApi,
SessionState,
} from '../../../definitions/auth';
import { OAuthRequestApi, AuthProvider } from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker';
type CreateOptions = {
// TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth
@@ -57,7 +61,8 @@ const DEFAULT_PROVIDER = {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
class GoogleAuth
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
static create({
apiOrigin,
basePath,
@@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
return new GoogleAuth(sessionManager);
}
private readonly sessionStateTracker = new SessionStateTracker();
sessionState$(): Observable<SessionState> {
return this.sessionStateTracker.observable;
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
@@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
...options,
scopes: normalizedScopes,
});
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.accessToken;
}
@@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
async getIdToken(options: IdTokenOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (session) {
return session.idToken;
}
@@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi {
async logout() {
await this.sessionManager.removeSession();
this.sessionStateTracker.setIsSignedId(false);
}
async getProfile(options: ProfileInfoOptions = {}) {
const session = await this.sessionManager.getSession(options);
this.sessionStateTracker.setIsSignedId(!!session);
if (!session) {
return undefined;
}
@@ -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?
}
}
+4 -1
View File
@@ -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>
);
+18 -152
View File
@@ -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';
+11
View File
@@ -5,10 +5,12 @@ import {
oauthRequestApiRef,
OAuthRequestManager,
googleAuthApiRef,
githubAuthApiRef,
AlertApiForwarder,
ErrorApiForwarder,
ErrorAlerter,
GoogleAuth,
GithubAuth,
} from '@backstage/core';
const builder = ApiRegistry.builder();
@@ -31,4 +33,13 @@ builder.add(
}),
);
builder.add(
githubAuthApiRef,
GithubAuth.create({
apiOrigin: 'http://localhost:7000',
basePath: '/auth/',
oauthRequestApi,
}),
);
export const apis = builder.build();
+5
View File
@@ -7,6 +7,11 @@ This is the backend part of the default catalog plugin.
It responds to requests from the frontend part, and fulfills them by delegating
to your existing catalog related services.
## Getting Started
After starting the backend, you can issue the `yarn mock-catalog-data` command
in this directory to populate the catalog with some mock entities.
## Links
- (Frontend part of the plugin)[https://github.com/spotify/backstage/tree/master/plugins/catalog]
@@ -0,0 +1,60 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: podcast-api
description: Podcast API
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-lookup
description: Artist Lookup
spec:
type: service
lifecycle: experimental
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: searcher
description: Searcher
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playback-order
description: Playback Order
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: shuffle-api
description: Shuffle API
spec:
type: service
lifecycle: production
owner: tools@example.com
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: queue-proxy
description: Queue Proxy
spec:
type: website
lifecycle: production
owner: tools@example.com
@@ -1,6 +0,0 @@
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: component3
spec:
type: service
@@ -1,14 +0,0 @@
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: playlist-proxy
spec:
type: service
---
apiVersion: backstage.io/v1beta1
kind: Component
metadata:
name: artist-web
spec:
type: website
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -5,5 +5,5 @@ curl \
--header 'Content-Type: application/json' \
--data-raw '{
"type": "github",
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/fixtures/two_components.yaml"
"target": "https://github.com/spotify/backstage/blob/master/plugins/catalog-backend/examples/example-components.yaml"
}'
@@ -28,6 +28,7 @@ describe('DatabaseEntitiesCatalog', () => {
updateEntity: jest.fn(),
entities: jest.fn(),
entity: jest.fn(),
entityByUid: jest.fn(),
removeEntity: jest.fn(),
addLocation: jest.fn(),
removeLocation: jest.fn(),
@@ -15,6 +15,9 @@
*/
import type { Entity } from '@backstage/catalog-model';
import { LOCATION_ANNOTATION } from '@backstage/catalog-model';
import { NotFoundError } from '@backstage/backend-common';
import type { Database, DbEntityResponse, EntityFilters } from '../database';
import type { EntitiesCatalog } from './types';
@@ -78,7 +81,28 @@ export class DatabaseEntitiesCatalog implements EntitiesCatalog {
async removeEntityByUid(uid: string): Promise<void> {
return await this.database.transaction(async tx => {
await this.database.removeEntity(tx, uid);
const entityResponse = await this.database.entityByUid(tx, uid);
if (!entityResponse) {
throw new NotFoundError(`Entity with ID ${uid} was not found`);
}
const location =
entityResponse.entity.metadata.annotations?.[LOCATION_ANNOTATION];
const colocatedEntities = location
? await this.database.entities(tx, [
{
key: LOCATION_ANNOTATION,
values: [location],
},
])
: [entityResponse];
for (const dbResponse of colocatedEntities) {
await this.database.removeEntity(tx, dbResponse?.entity.metadata.uid!);
}
if (entityResponse.locationId) {
await this.database.removeLocation(tx, entityResponse?.locationId!);
}
return undefined;
});
}
@@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog {
}
async removeLocation(id: string): Promise<void> {
await this.database.removeLocation(id);
await this.database.transaction(tx => this.database.removeLocation(tx, id));
}
async locations(): Promise<LocationResponse[]> {
@@ -105,8 +105,7 @@ describe('CommonDatabase', () => {
expect(locations).toEqual([output]);
const location = await db.location(locations[0].id);
expect(location).toEqual(output);
await db.removeLocation(locations[0].id);
await db.transaction(tx => db.removeLocation(tx, locations[0].id));
await expect(db.locations()).resolves.toEqual([]);
await expect(db.location(locations[0].id)).rejects.toThrow(
@@ -319,6 +319,21 @@ export class CommonDatabase implements Database {
return toEntityResponse(rows[0]);
}
async entityByUid(
txOpaque: unknown,
id: string,
): Promise<DbEntityResponse | undefined> {
const tx = txOpaque as Knex.Transaction<any, any>;
const rows = await tx<DbEntitiesRow>('entities').where({ id }).select();
if (rows.length !== 1) {
return undefined;
}
return toEntityResponse(rows[0]);
}
async removeEntity(txOpaque: unknown, uid: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
@@ -341,10 +356,10 @@ export class CommonDatabase implements Database {
});
}
async removeLocation(id: string): Promise<void> {
const result = await this.database<DbLocationsRow>('locations')
.where({ id })
.del();
async removeLocation(txOpaque: unknown, id: string): Promise<void> {
const tx = txOpaque as Knex.Transaction<any, any>;
const result = await tx<DbLocationsRow>('locations').where({ id }).del();
if (!result) {
throw new NotFoundError(`Found no location with ID ${id}`);
@@ -130,11 +130,13 @@ export type Database = {
namespace?: string,
): Promise<DbEntityResponse | undefined>;
entityByUid(tx: unknown, uid: string): Promise<DbEntityResponse | undefined>;
removeEntity(tx: unknown, uid: string): Promise<void>;
addLocation(location: Location): Promise<DbLocationsRow>;
removeLocation(id: string): Promise<void>;
removeLocation(tx: unknown, id: string): Promise<void>;
location(id: string): Promise<DbLocationsRowWithStatus>;
@@ -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');
});
});
+30 -5
View File
@@ -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;
}
}
+3 -1
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core';
import { Entity, Location } from '@backstage/catalog-model';
@@ -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>
);
@@ -13,21 +13,22 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ComponentContextMenu } from './ComponentContextMenu';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import * as React from 'react';
import { act } from 'react-dom/test-utils';
import { EntityContextMenu } from './EntityContextMenu';
describe('ComponentContextMenu', () => {
it('should call onUnregisterComponent on button click', async () => {
it('should call onUnregisterEntity on button click', async () => {
await act(async () => {
const mockCallback = jest.fn();
const menu = render(
<ComponentContextMenu onUnregisterComponent={mockCallback} />,
<EntityContextMenu onUnregisterEntity={mockCallback} />,
);
const button = await menu.findByTestId('menu-button');
button.click();
const unregister = await menu.findByText('Unregister component');
fireEvent.click(button);
const unregister = await menu.findByText('Unregister entity');
expect(unregister).toBeInTheDocument();
});
});
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
IconButton,
ListItemIcon,
@@ -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>
</>
);
};
@@ -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();
});
});
@@ -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>
@@ -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>,
),
);
@@ -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)}
/>
</>
)}
@@ -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
+22 -25
View File
@@ -13,30 +13,35 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Entity } from '@backstage/catalog-model';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import {
CatalogFilterGroup,
CatalogFilterItem,
} from '../components/CatalogFilter/CatalogFilter';
import SettingsIcon from '@material-ui/icons/Settings';
import StarIcon from '@material-ui/icons/Star';
import { StarredCount } from '../components/CatalogFilter/StarredCount';
import { AllServicesCount } from '../components/CatalogFilter/AllServicesCount';
import { FilterGroupItem } from '../types';
import { CatalogApi } from '../..';
import { Entity } from '@backstage/catalog-model';
export enum EntityFilterType {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
export const filterGroups: CatalogFilterGroup[] = [
{
name: 'Personal',
items: [
{
id: FilterGroupItem.OWNED,
id: EntityFilterType.OWNED,
label: 'Owned',
count: 0,
icon: SettingsIcon,
},
{
id: FilterGroupItem.STARRED,
id: EntityFilterType.STARRED,
label: 'Starred',
count: StarredCount,
icon: StarIcon,
@@ -48,7 +53,7 @@ export const filterGroups: CatalogFilterGroup[] = [
name: 'Company',
items: [
{
id: FilterGroupItem.ALL,
id: EntityFilterType.ALL,
label: 'All Services',
count: AllServicesCount,
},
@@ -56,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];
+2 -13
View File
@@ -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';
-1
View File
@@ -17,5 +17,4 @@
export { plugin } from './plugin';
export * from './api/CatalogClient';
export * from './api/types';
export * from './types';
export * from './routes';
+2 -2
View File
@@ -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);
},
});
-165
View File
@@ -1,165 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export interface ComponentDescriptorV1beta1 extends DescriptorEnvelope {
spec: {
type: string;
};
}
export type ComponentDescriptor = ComponentDescriptorV1beta1;
/**
* Metadata fields common to all versions/kinds of entity.
*
* @see https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.18/#objectmeta-v1-meta
*/
export type EntityMeta = {
/**
* A globally unique ID for the entity.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, but the server is free to reject requests
* that do so in such a way that it breaks semantics.
*/
uid?: string;
/**
* An opaque string that changes for each update operation to any part of
* the entity, including metadata.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations. The field can (optionally) be specified when performing
* update or delete operations, and the server will then reject the
* operation if it does not match the current stored value.
*/
etag?: string;
/**
* A positive nonzero number that indicates the current generation of data
* for this entity; the value is incremented each time the spec changes.
*
* This field can not be set by the user at creation time, and the server
* will reject an attempt to do so. The field will be populated in read
* operations.
*/
generation?: number;
/**
* The name of the entity.
*
* Must be uniqe within the catalog at any given point in time, for any
* given namespace, for any given kind.
*/
name: string;
/**
* The short description of the entity.
*
* A a human readable string.
*/
description: string;
/**
* The namespace that the entity belongs to.
*/
namespace?: string;
/**
* Key/value pairs of identifying information attached to the entity.
*/
labels?: Record<string, string>;
/**
* Key/value pairs of non-identifying auxiliary information attached to the
* entity.
*/
annotations?: Record<string, string>;
};
/**
* The format envelope that's common to all versions/kinds.
*
* @see https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/
*/
export type DescriptorEnvelope = {
/**
* The version of specification format for this particular entity that
* this is written against.
*/
apiVersion: string;
/**
* The high level entity type being described.
*/
kind: string;
/**
* Optional metadata related to the entity.
*/
metadata: EntityMeta;
/**
* The specification data describing the entity itself.
*/
spec?: object;
};
/**
* Parses and validates descriptors.
*
* The output must be validated and well formed.
*/
export type DescriptorParser = {
/**
* Parses and validates a single raw descriptor.
*
* @param descriptor A raw descriptor object
* @returns A structure describing the parsed and validated descriptor
* @throws An Error if the descriptor was malformed
*/
parse(descriptor: object): Promise<DescriptorEnvelope>;
};
/**
* Parses and validates a single envelope into its materialized kind.
*
* These parsers may assume that the envelope is already validated and well
* formed.
*/
export type KindParser = {
/**
* Try to parse an envelope into a materialized kind.
*
* @param envelope A valid descriptor envelope
* @returns A materialized type, or undefined if the given version/kind is
* not meant to be handled by this parser
* @throws An Error if the type was handled and found to not be properly
* formatted
*/
tryParse(
envelope: DescriptorEnvelope,
): Promise<DescriptorEnvelope | undefined>;
};
export enum FilterGroupItem {
ALL = 'ALL',
STARRED = 'STARRED',
OWNED = 'OWNED',
}
@@ -15,20 +15,12 @@
*/
import React from 'react';
import { Header, Page, pageTheme, HeaderLabel } from '@backstage/core';
import { Box } from '@material-ui/core';
export const Layout: React.FC = ({ children }) => {
return (
<Page theme={pageTheme.tool}>
<Header
pageTitleOverride="Circle CI"
title={
<Box display="flex" alignItems="center">
<Box mr={1} /> Circle CI
</Box>
}
>
<HeaderLabel label="Owner" value="Team X" />
<Header title="CircleCI" subtitle="See recent builds and their status">
<HeaderLabel label="Owner" value="Spotify" />
<HeaderLabel label="Lifecycle" value="Alpha" />
</Header>
{children}
@@ -80,6 +80,14 @@ const toolsCards = [
'https://camo.githubusercontent.com/517398c3fbe0687d3d4dcbe05da82970b882e75a/68747470733a2f2f64337676366c703535716a6171632e636c6f756466726f6e742e6e65742f6974656d732f33413061324e314c3346324f304c3377326e316a2f477261706869514c382e706e673f582d436c6f75644170702d56697369746f722d49643d3433363432',
tags: ['graphql', 'dev'],
},
{
title: 'GitOps Clusters',
description:
'Create GitOps-managed clusters with Backstage. Currently supports EKS flavors and profiles like Machine Learning Ops (MLOps)',
url: '/gitops-clusters',
image: 'https://miro.medium.com/max/801/1*R28u8gj-hVdDFISoYqPhrQ.png',
tags: ['gitops', 'dev'],
},
];
const ExplorePluginPage: FC<{}> = () => {
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
extends: [require.resolve('@backstage/cli/config/eslint')],
};
+26
View File
@@ -0,0 +1,26 @@
# gitops-profiles
Welcome to the gitops-profiles plugin!
This plugin is for creating GitOps-managed Kubernetes clusters. Currently, it supports provisioning EKS clusters on GitHub via GitHub Actions.
_This plugin was created through the Backstage CLI_
## Plugin Development
Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/gitops-clusters](http://localhost:3000/gitops-profiles).
You can also serve the plugin in isolation by running `yarn start` in the plugin directory.
This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads.
It is only meant for local development, and the setup for it can be found inside the [/dev](/dev) directory.
## Use GitOps-API backend with Backstage
The backend of this plugin is written in Golang and its source code is available [here](https://github.com/chanwit/gitops-api) as a separate GitHub repository.
The binary of this plugin is available as a ready-to-use Docker image, [https://hub.docker.com/chanwit/gitops-api](https://hub.docker.com/chanwit/gitops-api).
To start using GitOps with Backstage, you have to start the backend using the following command:
```bash
$ docker run -d --init -p 3008:8080 chanwit/gitops-api
```
Please note that this plugin requires the backend to run on port 3008.
@@ -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();
+49
View File
@@ -0,0 +1,49 @@
{
"name": "@backstage/plugin-gitops-profiles",
"version": "0.1.1-alpha.7",
"main": "dist/index.esm.js",
"main:src": "src/index.ts",
"types": "src/index.ts",
"license": "Apache-2.0",
"private": true,
"publishConfig": {
"access": "public",
"main": "dist/index.esm.js",
"types": "dist/index.d.ts"
},
"scripts": {
"build": "backstage-cli plugin:build",
"start": "backstage-cli plugin:serve",
"lint": "backstage-cli lint",
"test": "backstage-cli test",
"diff": "backstage-cli plugin:diff",
"prepack": "backstage-cli prepack",
"postpack": "backstage-cli postpack",
"clean": "backstage-cli clean"
},
"dependencies": {
"@backstage/core": "^0.1.1-alpha.7",
"@backstage/theme": "^0.1.1-alpha.7",
"@material-ui/core": "^4.9.1",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "4.0.0-alpha.45",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-use": "^14.2.0",
"react-router-dom": "^5.2.0"
},
"devDependencies": {
"@backstage/cli": "^0.1.1-alpha.7",
"@backstage/dev-utils": "^0.1.1-alpha.7",
"@testing-library/jest-dom": "^5.7.0",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^10.2.4",
"@types/jest": "^25.2.2",
"@types/node": "^12.0.0",
"@types/testing-library__jest-dom": "^5.0.4",
"jest-fetch-mock": "^3.0.3"
},
"files": [
"dist/**/*.{js,d.ts}"
]
}
+170
View File
@@ -0,0 +1,170 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createApiRef } from '@backstage/core-api';
export interface CloneFromTemplateRequest {
templateRepository: string;
secrets: {
awsAccessKeyId: string;
awsSecretAccessKey: string;
};
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface ApplyProfileRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
profiles: string[];
}
export interface ChangeClusterStateRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
clusterState: 'present' | 'absent'; // /api/cluster/state
}
export interface PollLogRequest {
targetOrg: string;
targetRepo: string;
gitHubUser: string;
gitHubToken: string;
}
export interface Status {
status: string; // queued, in_progress, or completed
message: string;
conclusion: string; // success, failure, neutral, cancelled, skipped, timed_out, or action_required
}
export interface StatusResponse {
result: Status[];
link: string;
status: string;
}
export interface ClusterStatus {
name: string;
link: string;
status: string;
conclusion: string;
runStatus: Status[];
}
export interface ListClusterStatusesResponse {
result: ClusterStatus[];
}
export interface ListClusterRequest {
gitHubUser: string;
gitHubToken: string;
}
export class FetchError extends Error {
get name(): string {
return this.constructor.name;
}
static async forResponse(resp: Response): Promise<FetchError> {
return new FetchError(
`Request failed with status code ${
resp.status
}.\nReason: ${await resp.text()}`,
);
}
}
export type GitOpsApi = {
url: string;
fetchLog(req: PollLogRequest): Promise<StatusResponse>;
changeClusterState(req: ChangeClusterStateRequest): Promise<any>;
cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any>;
applyProfiles(req: ApplyProfileRequest): Promise<any>;
listClusters(req: ListClusterRequest): Promise<ListClusterStatusesResponse>;
};
export const gitOpsApiRef = createApiRef<GitOpsApi>({
id: 'plugin.gitops.service',
description: 'Used by the GitOps profiles plugin to make requests',
});
export class GitOpsRestApi implements GitOpsApi {
constructor(public url: string = '') {}
private async fetch<T = any>(path: string, init?: RequestInit): Promise<T> {
const resp = await fetch(`${this.url}${path}`, init);
if (!resp.ok) throw await FetchError.forResponse(resp);
return await resp.json();
}
async fetchLog(req: PollLogRequest): Promise<StatusResponse> {
return await this.fetch<StatusResponse>(`/api/cluster/run-status`, {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async changeClusterState(req: ChangeClusterStateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/state', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async cloneClusterFromTemplate(req: CloneFromTemplateRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/clone-from-template', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async applyProfiles(req: ApplyProfileRequest): Promise<any> {
return await this.fetch<any>('/api/cluster/profiles', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
async listClusters(
req: ListClusterRequest,
): Promise<ListClusterStatusesResponse> {
return await this.fetch<ListClusterStatusesResponse>('/api/clusters', {
method: 'post',
headers: new Headers({
'Content-Type': 'application/json',
}),
body: JSON.stringify(req),
});
}
}
@@ -0,0 +1,95 @@
/*
* Copyright 2020 Spotify AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { FC } from 'react';
import {
Content,
ContentHeader,
Header,
SupportButton,
Page,
pageTheme,
Progress,
HeaderLabel,
useApi,
} from '@backstage/core';
import ClusterTable from '../ClusterTable/ClusterTable';
import { Button, Typography } from '@material-ui/core';
import { useAsync, useLocalStorage } from 'react-use';
import { gitOpsApiRef, ListClusterStatusesResponse } from '../../api';
const ClusterList: FC<{}> = () => {
const [loginInfo] = useLocalStorage<{
token: string;
username: string;
name: string;
}>('githubLoginDetails');
const api = useApi(gitOpsApiRef);
const { loading, error, value } = useAsync<ListClusterStatusesResponse>(
() => {
return api.listClusters({
gitHubToken: loginInfo.token,
gitHubUser: loginInfo.username,
});
},
);
let content: JSX.Element;
if (loading) {
content = (
<Content>
<Progress />
</Content>
);
} else if (error) {
content = (
<Content>
<Typography variant="h4" color="error">
Failed to load cluster, {String(error)}
</Typography>
</Content>
);
} else {
content = (
<Content>
<ContentHeader title="Clusters">
<Button
variant="contained"
color="primary"
href="/gitops-cluster-create"
>
Create GitOps-managed Cluster
</Button>
<SupportButton>All clusters</SupportButton>
</ContentHeader>
<ClusterTable components={value!.result} />
</Content>
);
}
return (
<Page theme={pageTheme.home}>
<Header title="GitOps-managed Clusters">
<HeaderLabel label="Welcome" value={loginInfo.name} />
</Header>
{content}
</Page>
);
};
export default ClusterList;
@@ -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';
@@ -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';
+18
View File
@@ -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();
});
});
+29
View File
@@ -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);
},
});
+18
View File
@@ -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();
@@ -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 = () => ({
+5 -1
View File
@@ -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} />
+18
View File
@@ -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' },