diff --git a/packages/app/package.json b/packages/app/package.json index d1abda4af2..5af418f98e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -8,6 +8,7 @@ "@backstage/plugin-catalog": "^0.1.1-alpha.7", "@backstage/plugin-circleci": "^0.1.1-alpha.7", "@backstage/plugin-explore": "^0.1.1-alpha.7", + "@backstage/plugin-gitops-profiles": "^0.1.1-alpha.7", "@backstage/plugin-home-page": "^0.1.1-alpha.7", "@backstage/plugin-lighthouse": "^0.1.1-alpha.7", "@backstage/plugin-register-component": "^0.1.1-alpha.7", diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index ec0088e219..4d78ef1431 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -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; diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index 0e30c15254..8b1ce76cbd 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -19,6 +19,9 @@ import PropTypes from 'prop-types'; import { Link, makeStyles } from '@material-ui/core'; import HomeIcon from '@material-ui/icons/Home'; import ExploreIcon from '@material-ui/icons/Explore'; +import BuildIcon from '@material-ui/icons/BuildRounded'; +import RuleIcon from '@material-ui/icons/AssignmentTurnedIn'; +import MapIcon from '@material-ui/icons/MyLocation'; import CreateComponentIcon from '@material-ui/icons/AddCircleOutline'; import LogoFull from './LogoFull'; import LogoIcon from './LogoIcon'; @@ -88,6 +91,9 @@ const Root: FC<{}> = ({ children }) => ( {/* End global nav */} + + + diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 25a5bcfc9d..2cf2bde9d5 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -23,3 +23,4 @@ export { plugin as Explore } from '@backstage/plugin-explore'; export { plugin as Circleci } from '@backstage/plugin-circleci'; export { plugin as RegisterComponent } from '@backstage/plugin-register-component'; export { plugin as Sentry } from '@backstage/plugin-sentry'; +export { plugin as GitopsProfiles } from '@backstage/plugin-gitops-profiles'; diff --git a/packages/backend/README.md b/packages/backend/README.md index 48dd7f09fe..e9bf4199ff 100644 --- a/packages/backend/README.md +++ b/packages/backend/README.md @@ -39,19 +39,14 @@ If you want to use the catalog functionality, you need to add so called location to the backend. These are places where the backend can find some entity descriptor data to consume and serve. -To get started, you can issue the following after starting the backend: +To get started, you can issue the following after starting the backend, from inside +the `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. diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index 4cbc8bcfb1..7abab45780 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -15,6 +15,7 @@ */ import { createApiRef } from '../ApiRef'; +import { Observable } from '../..'; /** * This file contains declarations for common interfaces of auth-related APIs. @@ -167,6 +168,14 @@ export type ProfileInfo = { picture?: string; }; +export enum SessionState { + SignedIn = 'SignedIn', + SignedOut = 'SignedOut', +} + +export type SessionStateApi = { + sessionState$(): Observable; +}; /** * Provides authentication towards Google APIs and identities. * @@ -176,7 +185,7 @@ export type ProfileInfo = { * email and expiration information. Do not rely on any other fields, as they might not be present. */ export const googleAuthApiRef = createApiRef< - OAuthApi & OpenIdConnectApi & ProfileInfoApi + OAuthApi & OpenIdConnectApi & ProfileInfoApi & SessionStateApi >({ id: 'core.auth.google', description: 'Provides authentication towards Google APIs and identities', @@ -188,7 +197,7 @@ export const googleAuthApiRef = createApiRef< * See https://developer.github.com/apps/building-oauth-apps/understanding-scopes-for-oauth-apps/ * for a full list of supported scopes. */ -export const githubAuthApiRef = createApiRef({ +export const githubAuthApiRef = createApiRef({ id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); diff --git a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts index d75bceef97..ac05b718e5 100644 --- a/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/github/GithubAuth.ts @@ -17,10 +17,17 @@ import GithubIcon from '@material-ui/icons/AcUnit'; import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { GithubSession } from './types'; -import { OAuthApi, AccessTokenOptions } from '../../../definitions/auth'; +import { + OAuthApi, + AccessTokenOptions, + SessionStateApi, + SessionState, +} from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GithubAuth @@ -46,7 +53,7 @@ const DEFAULT_PROVIDER = { icon: GithubIcon, }; -class GithubAuth implements OAuthApi { +class GithubAuth implements OAuthApi, SessionStateApi { static create({ apiOrigin, basePath, @@ -78,6 +85,12 @@ class GithubAuth implements OAuthApi { return new GithubAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken(scope?: string, options?: AccessTokenOptions) { @@ -86,6 +99,7 @@ class GithubAuth implements OAuthApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -94,6 +108,7 @@ class GithubAuth implements OAuthApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } static normalizeScope(scope?: string): Set { diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index d65a2c71ee..1fc6f4f6b8 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -25,10 +25,14 @@ import { ProfileInfoApi, ProfileInfoOptions, ProfileInfo, + SessionStateApi, + SessionState, } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider } from '../../../definitions'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; +import { SessionStateTracker } from '../../../../lib/AuthSessionManager/SessionStateTracker'; type CreateOptions = { // TODO(Rugvip): These two should be grabbed from global config when available, they're not unique to GoogleAuth @@ -57,7 +61,8 @@ const DEFAULT_PROVIDER = { const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; -class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { +class GoogleAuth + implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { static create({ apiOrigin, basePath, @@ -99,6 +104,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { return new GoogleAuth(sessionManager); } + private readonly sessionStateTracker = new SessionStateTracker(); + + sessionState$(): Observable { + return this.sessionStateTracker.observable; + } + constructor(private readonly sessionManager: SessionManager) {} async getAccessToken( @@ -110,6 +121,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { ...options, scopes: normalizedScopes, }); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.accessToken; } @@ -118,6 +130,7 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async getIdToken(options: IdTokenOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (session) { return session.idToken; } @@ -126,10 +139,12 @@ class GoogleAuth implements OAuthApi, OpenIdConnectApi, ProfileInfoApi { async logout() { await this.sessionManager.removeSession(); + this.sessionStateTracker.setIsSignedId(false); } async getProfile(options: ProfileInfoOptions = {}) { const session = await this.sessionManager.getSession(options); + this.sessionStateTracker.setIsSignedId(!!session); if (!session) { return undefined; } diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index ee7cbab2ae..793c6f1708 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -131,15 +131,6 @@ describe('RefreshingAuthSessionManager', () => { }); it('should remove session and reload', async () => { - // This is a workaround that is used by Facebook and the Jest core team - // It is a limitation with the newest versions of JSDOM, and newer browser standards - // where window.location and all of its properties are read-only. So we re-construct it! - // See https://github.com/facebook/jest/issues/890#issuecomment-209698782 - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new RefreshingAuthSessionManager({ connector: { removeSession }, @@ -147,7 +138,7 @@ describe('RefreshingAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 3df21af3f3..f7d5bcf7ca 100644 --- a/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -113,8 +113,8 @@ export class RefreshingAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } async getCurrentSession() { diff --git a/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts new file mode 100644 index 0000000000..de308acb0c --- /dev/null +++ b/packages/core-api/src/lib/AuthSessionManager/SessionStateTracker.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BehaviorSubject } from '..'; +import { SessionState } from '../../apis'; + +export class SessionStateTracker { + private signedIn: boolean = false; + observable = new BehaviorSubject(SessionState.SignedOut); + + setIsSignedId(isSignedIn: boolean) { + if (this.signedIn !== isSignedIn) { + this.signedIn = isSignedIn; + this.observable.next( + this.signedIn ? SessionState.SignedIn : SessionState.SignedOut, + ); + } + } +} diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts index 7d47fa91df..6280750875 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.test.ts @@ -84,11 +84,6 @@ describe('StaticAuthSessionManager', () => { }); it('should remove session and reload', async () => { - const location = { ...window.location }; - delete window.location; - window.location = location; - jest.spyOn(window.location, 'reload').mockImplementation(); - const removeSession = jest.fn(); const manager = new StaticAuthSessionManager({ connector: { removeSession }, @@ -96,7 +91,7 @@ describe('StaticAuthSessionManager', () => { } as any); await manager.removeSession(); - expect(window.location.reload).toHaveBeenCalled(); expect(removeSession).toHaveBeenCalled(); + expect(await manager.getSession({ optional: true })).toBe(undefined); }); }); diff --git a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts index 6e6db47a99..5ecbbc0c4e 100644 --- a/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts +++ b/packages/core-api/src/lib/AuthSessionManager/StaticAuthSessionManager.ts @@ -64,7 +64,7 @@ export class StaticAuthSessionManager implements SessionManager { } async removeSession() { + this.currentSession = undefined; await this.connector.removeSession(); - window.location.reload(); // TODO(Rugvip): make this work without reload? } } diff --git a/packages/core/src/layout/Sidebar/Items.tsx b/packages/core/src/layout/Sidebar/Items.tsx index e5785c5323..5c2909f82e 100644 --- a/packages/core/src/layout/Sidebar/Items.tsx +++ b/packages/core/src/layout/Sidebar/Items.tsx @@ -58,8 +58,11 @@ const useStyles = makeStyles(theme => { // XXX (@koroeskohr): I can't seem to achieve the desired font-weight from the designs fontWeight: 'bold', whiteSpace: 'nowrap', - lineHeight: 1.0, + lineHeight: 'auto', flex: '3 1 auto', + width: '110px', + overflow: 'hidden', + 'text-overflow': 'ellipsis', }, iconContainer: { boxSizing: 'border-box', diff --git a/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx b/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx new file mode 100644 index 0000000000..b81ba2aaf7 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/OAuthProviderSettings.tsx @@ -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; +}; + +export const OAuthProviderSettings: FC = ({ + 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 ( + api.getAccessToken()} + /> + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx b/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx new file mode 100644 index 0000000000..139d79213f --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/OIDCProviderSettings.tsx @@ -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; +}; + +export const OIDCProviderSettings: FC = ({ + 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 ( + api.getIdToken()} + /> + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx new file mode 100644 index 0000000000..8fbc945cc2 --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/ProviderSettingsItem.tsx @@ -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 ( + + (signedIn ? api.logout() : signInHandler())}> + + + + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx b/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx new file mode 100644 index 0000000000..ebf1d0877d --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/UserProfile.tsx @@ -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(); + const ref = useRef(); // 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 + ? () => ( + + ) + : () => ; + } + + return ( + <> + + + {open ? : } + + + ); +}; diff --git a/packages/core/src/layout/Sidebar/Settings/index.ts b/packages/core/src/layout/Sidebar/Settings/index.ts new file mode 100644 index 0000000000..6557ace53a --- /dev/null +++ b/packages/core/src/layout/Sidebar/Settings/index.ts @@ -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'; diff --git a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx index a450a09d88..906317fe9c 100644 --- a/packages/core/src/layout/Sidebar/Sidebar.stories.tsx +++ b/packages/core/src/layout/Sidebar/Sidebar.stories.tsx @@ -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 = () => ( + ); diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 5179356cb8..8ca2580df9 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -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([ - { - 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(); // for scrolling down when collapse item opens - const providers = useProviders(); - const [profile, setProfile] = useState(); - 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 - ? () => - : () => ( - - {avatarFallback[0]} - - ); - } - return ( <> - - - {open ? : } - - - {providers.map((provider: Provider) => ( - - - provider.isSignedIn - ? provider.api.logout() - : provider.api.getAccessToken() - } - > - - - - - - ))} + + + + ); diff --git a/packages/core/src/layout/Sidebar/index.ts b/packages/core/src/layout/Sidebar/index.ts index ec7e532333..355ead14e0 100644 --- a/packages/core/src/layout/Sidebar/index.ts +++ b/packages/core/src/layout/Sidebar/index.ts @@ -34,3 +34,4 @@ export { export type { SidebarContextType } from './config'; export { SidebarThemeToggle } from './SidebarThemeToggle'; export { SidebarUserSettings } from './UserSettings'; +export * from './Settings'; diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index cacc5af354..d3150400cf 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -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(); diff --git a/plugins/catalog-backend/README.md b/plugins/catalog-backend/README.md index 051bc50417..8009fa89c0 100644 --- a/plugins/catalog-backend/README.md +++ b/plugins/catalog-backend/README.md @@ -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] diff --git a/plugins/catalog-backend/examples/example-components.yaml b/plugins/catalog-backend/examples/example-components.yaml new file mode 100644 index 0000000000..4580fa7179 --- /dev/null +++ b/plugins/catalog-backend/examples/example-components.yaml @@ -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 diff --git a/plugins/catalog-backend/fixtures/one_component.yaml b/plugins/catalog-backend/fixtures/one_component.yaml deleted file mode 100644 index 421f66f7a8..0000000000 --- a/plugins/catalog-backend/fixtures/one_component.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: backstage.io/v1beta1 -kind: Component -metadata: - name: component3 -spec: - type: service diff --git a/plugins/catalog-backend/fixtures/two_components.yaml b/plugins/catalog-backend/fixtures/two_components.yaml deleted file mode 100644 index 2fcacb8492..0000000000 --- a/plugins/catalog-backend/fixtures/two_components.yaml +++ /dev/null @@ -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 diff --git a/plugins/catalog-backend/package.json b/plugins/catalog-backend/package.json index 761c042723..1aeea2538f 100644 --- a/plugins/catalog-backend/package.json +++ b/plugins/catalog-backend/package.json @@ -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", diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 2342f09fa4..c9e4e35d7b 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -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" }' diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts index eb9cb2439c..30b480f348 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.test.ts @@ -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(), diff --git a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts index 1ec1ebf6e5..6ab660a587 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseEntitiesCatalog.ts @@ -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 { 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; }); } diff --git a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts index a1e76679f7..f515829b91 100644 --- a/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts +++ b/plugins/catalog-backend/src/catalog/DatabaseLocationsCatalog.ts @@ -31,7 +31,7 @@ export class DatabaseLocationsCatalog implements LocationsCatalog { } async removeLocation(id: string): Promise { - await this.database.removeLocation(id); + await this.database.transaction(tx => this.database.removeLocation(tx, id)); } async locations(): Promise { diff --git a/plugins/catalog-backend/src/database/CommonDatabase.test.ts b/plugins/catalog-backend/src/database/CommonDatabase.test.ts index f1723e3733..1802ea5e7d 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.test.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.test.ts @@ -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( diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index 882995c142..2a1b33bbbe 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -319,6 +319,21 @@ export class CommonDatabase implements Database { return toEntityResponse(rows[0]); } + async entityByUid( + txOpaque: unknown, + id: string, + ): Promise { + const tx = txOpaque as Knex.Transaction; + + const rows = await tx('entities').where({ id }).select(); + + if (rows.length !== 1) { + return undefined; + } + + return toEntityResponse(rows[0]); + } + async removeEntity(txOpaque: unknown, uid: string): Promise { const tx = txOpaque as Knex.Transaction; @@ -341,10 +356,10 @@ export class CommonDatabase implements Database { }); } - async removeLocation(id: string): Promise { - const result = await this.database('locations') - .where({ id }) - .del(); + async removeLocation(txOpaque: unknown, id: string): Promise { + const tx = txOpaque as Knex.Transaction; + + const result = await tx('locations').where({ id }).del(); if (!result) { throw new NotFoundError(`Found no location with ID ${id}`); diff --git a/plugins/catalog-backend/src/database/types.ts b/plugins/catalog-backend/src/database/types.ts index 17da373ae9..fc81e124ba 100644 --- a/plugins/catalog-backend/src/database/types.ts +++ b/plugins/catalog-backend/src/database/types.ts @@ -130,11 +130,13 @@ export type Database = { namespace?: string, ): Promise; + entityByUid(tx: unknown, uid: string): Promise; + removeEntity(tx: unknown, uid: string): Promise; addLocation(location: Location): Promise; - removeLocation(id: string): Promise; + removeLocation(tx: unknown, id: string): Promise; location(id: string): Promise; diff --git a/plugins/catalog/src/api/CatalogClient.test.ts b/plugins/catalog/src/api/CatalogClient.test.ts index 027fd41069..d626c76b30 100644 --- a/plugins/catalog/src/api/CatalogClient.test.ts +++ b/plugins/catalog/src/api/CatalogClient.test.ts @@ -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'); }); }); diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index dcaab53307..d8add18f68 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -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, - ): Promise { - const cachedValue = this.cache.get( + filter?: Record, + ): Promise { + const cachedValue = this.cache.get( `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 { + 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; + } } diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 9edf0358d1..3ac8a5d103 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -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; - getEntities(filter?: Record): Promise; + getEntities(filter?: Record): Promise; addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; + removeEntityByUid(uid: string): Promise; } export type AddLocationResponse = { location: Location; entities: Entity[] }; diff --git a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx index 07879770b7..5ccac8f772 100644 --- a/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/AllServicesCount.tsx @@ -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'; diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx index 6246726fab..b78450d549 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.test.tsx @@ -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: () => BACKSTAGE!, }, { - id: FilterGroupItem.STARRED, + id: EntityFilterType.STARRED, label: 'Second Label', count: 400, }, diff --git a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx index 6541a4df8d..5335b33ffa 100644 --- a/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx +++ b/plugins/catalog/src/components/CatalogFilter/CatalogFilter.tsx @@ -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; diff --git a/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx index 20e1be1a9a..5e79682783 100644 --- a/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx +++ b/plugins/catalog/src/components/CatalogFilter/StarredCount.tsx @@ -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'; diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx index d995556546..d30361d0e7 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.test.tsx @@ -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(); diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index eac753d9fa..570648f26b 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -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 - All your components + All your software catalog entities
diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 6d38319fe9..c04e4c95a8 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -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( = ({ return (
- Error encountered while fetching components. {error.toString()} + Error encountered while fetching catalog entities. {error.toString()}
); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx similarity index 72% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx index 8a1f513f67..68306944a3 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.test.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.test.tsx @@ -13,21 +13,22 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ComponentContextMenu } from './ComponentContextMenu'; -import { render } from '@testing-library/react'; + +import { render, fireEvent } from '@testing-library/react'; import * as React from 'react'; import { act } from 'react-dom/test-utils'; +import { EntityContextMenu } from './EntityContextMenu'; describe('ComponentContextMenu', () => { - it('should call onUnregisterComponent on button click', async () => { + it('should call onUnregisterEntity on button click', async () => { await act(async () => { const mockCallback = jest.fn(); const menu = render( - , + , ); const button = await menu.findByTestId('menu-button'); - button.click(); - const unregister = await menu.findByText('Unregister component'); + fireEvent.click(button); + const unregister = await menu.findByText('Unregister entity'); expect(unregister).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx similarity index 88% rename from plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx rename to plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx index 14c68ec7f9..fd52b97cb1 100644 --- a/plugins/catalog/src/components/ComponentContextMenu/ComponentContextMenu.tsx +++ b/plugins/catalog/src/components/EntityContextMenu/EntityContextMenu.tsx @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { IconButton, ListItemIcon, @@ -34,13 +35,11 @@ const useStyles = makeStyles({ }, }); -type ComponentContextMenuProps = { - onUnregisterComponent: () => void; +type Props = { + onUnregisterEntity: () => void; }; -export const ComponentContextMenu: FC = ({ - onUnregisterComponent, -}) => { +export const EntityContextMenu: FC = ({ onUnregisterEntity }) => { const [anchorEl, setAnchorEl] = useState(); const classes = useStyles(); @@ -53,7 +52,7 @@ export const ComponentContextMenu: FC = ({ }; return ( -
+ <> = ({ { onClose(); - onUnregisterComponent(); + onUnregisterEntity(); }} > - Unregister component + Unregister entity @@ -91,6 +90,6 @@ export const ComponentContextMenu: FC = ({ -
+ ); }; diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx similarity index 77% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx rename to plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx index ef12e52363..e0027431ff 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.test.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.test.tsx @@ -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( - , - ); + const rendered = await render(); expect(await rendered.findByText('test')).toBeInTheDocument(); }); }); diff --git a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx similarity index 93% rename from plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx rename to plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx index 60c9284121..d7a3bc81f6 100644 --- a/plugins/catalog/src/components/ComponentMetadataCard/ComponentMetadataCard.tsx +++ b/plugins/catalog/src/components/EntityMetadataCard/EntityMetadataCard.tsx @@ -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 = ({ entity }) => ( +export const EntityMetadataCard: FC = ({ entity }) => ( diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx similarity index 85% rename from plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx rename to plugins/catalog/src/components/EntityPage/EntityPage.test.tsx index 75393bd10e..22a47c192e 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.test.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.test.tsx @@ -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', () => { ], ])} > - + , ), ); diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/EntityPage/EntityPage.tsx similarity index 82% rename from plugins/catalog/src/components/ComponentPage/ComponentPage.tsx rename to plugins/catalog/src/components/EntityPage/EntityPage.tsx index 17ad2d880d..96132819e8 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/EntityPage/EntityPage.tsx @@ -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 = ({ match, history }) => { +export const EntityPage: FC = ({ match, history }) => { const { optionalNamespaceAndName, kind } = match.params; const [name, namespace] = optionalNamespaceAndName.split(':').reverse(); @@ -83,7 +83,7 @@ export const ComponentPage: FC = ({ 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 = ({ 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 = ({ match, history }) => { // TODO: Switch theme and type props based on component type (website, library, ...)
- {entity && ( - - )} + {entity && }
{loading && } @@ -163,7 +159,7 @@ export const ComponentPage: FC = ({ match, history }) => { - + = ({ match, history }) => { - setConfirmationDialogOpen(false)} /> )} diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx similarity index 80% rename from plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx rename to plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx index aa8f99e01e..e4eec3c21f 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/UnregisterEntityDialog/UnregisterEntityDialog.tsx @@ -15,7 +15,7 @@ */ import { Entity, LOCATION_ANNOTATION } from '@backstage/catalog-model'; -import { Progress, useApi } from '@backstage/core'; +import { Progress, useApi, alertApiRef } from '@backstage/core'; import { Button, Dialog, @@ -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 { }, [catalogApi, entity]); } -export const ComponentRemovalDialog: FC = ({ +export const UnregisterEntityDialog: FC = ({ open, onConfirm, onClose, @@ -59,11 +59,24 @@ export const ComponentRemovalDialog: FC = ({ const { value: entities, loading, error } = useColocatedEntities(entity); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const catalogApi = useApi(catalogApiRef); + const alertApi = useApi(alertApiRef); + + const removeEntity = async () => { + const uid = entity.metadata.uid; + try { + await catalogApi.removeEntityByUid(uid!); + } catch (err) { + alertApi.post({ message: err.message }); + } + + onConfirm(); + }; return ( - Are you sure you want to unregister this component? + Are you sure you want to unregister this entity? {loading ? : null} @@ -90,21 +103,23 @@ export const ComponentRemovalDialog: FC = ({
  • - {entities[0]?.metadata?.annotations?.[LOCATION_ANNOTATION]} + {entities[0]?.metadata.annotations?.[LOCATION_ANNOTATION]}
- To undo, just re-register the component in Backstage. + To undo, just re-register the entity in Backstage. ) : null}
- + + All clusters + + + + ); + } + + return ( + +
+ +
+ {content} +
+ ); +}; + +export default ClusterList; diff --git a/plugins/gitops-profiles/src/components/ClusterList/index.ts b/plugins/gitops-profiles/src/components/ClusterList/index.ts new file mode 100644 index 0000000000..e4260e5374 --- /dev/null +++ b/plugins/gitops-profiles/src/components/ClusterList/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { default } from './ClusterList'; diff --git a/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx new file mode 100644 index 0000000000..29559e4fec --- /dev/null +++ b/plugins/gitops-profiles/src/components/ClusterPage/ClusterPage.tsx @@ -0,0 +1,103 @@ +/* + * Copyright 2020 Spotify AB + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useState } from 'react'; +import { + Content, + Header, + Page, + pageTheme, + Table, + Progress, + HeaderLabel, + useApi, +} from '@backstage/core'; + +import { Link } from '@material-ui/core'; +import { useParams } from 'react-router-dom'; +import { useLocalStorage } from 'react-use'; +import { gitOpsApiRef, Status } from '../../api'; +import { transformRunStatus } from '../ProfileCatalog'; + +const ClusterPage: FC<{}> = () => { + const params = useParams<{ owner: string; repo: string }>(); + + const [loginInfo] = useLocalStorage<{ + token: string; + username: string; + name: string; + }>('githubLoginDetails'); + + const [pollingLog, setPollingLog] = useState(true); + const [runStatus, setRunStatus] = useState([]); + const [runLink, setRunLink] = useState(''); + const [showProgress, setShowProgress] = useState(true); + + const api = useApi(gitOpsApiRef); + + const columns = [ + { field: 'status', title: 'Status' }, + { field: 'message', title: 'Message' }, + ]; + + useEffect(() => { + if (pollingLog) { + const interval = setInterval(async () => { + const resp = await api.fetchLog({ + gitHubToken: loginInfo.token, + gitHubUser: loginInfo.username, + targetOrg: params.owner, + targetRepo: params.repo, + }); + + setRunStatus(resp.result); + setRunLink(resp.link); + if (resp.status === 'completed') { + setPollingLog(false); + setShowProgress(false); + } + }, 10000); + return () => clearInterval(interval); + } + return () => {}; + }, [pollingLog, api, loginInfo, params]); + + return ( + +
+ +
+ +