diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts new file mode 100644 index 0000000000..ab7a90249a --- /dev/null +++ b/packages/catalog-model/src/location/annotation.ts @@ -0,0 +1,16 @@ +/* + * 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 const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index 60465bff9b..ff696524ab 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -16,3 +16,4 @@ export type { Location, LocationSpec } from './types'; export { locationSchema, locationSpecSchema } from './validation'; +export { LOCATION_ANNOTATION } from './annotation'; diff --git a/plugins/catalog-backend/scripts/mock-data b/plugins/catalog-backend/scripts/mock-data index 7edc8c422b..2342f09fa4 100755 --- a/plugins/catalog-backend/scripts/mock-data +++ b/plugins/catalog-backend/scripts/mock-data @@ -1,7 +1,7 @@ #!/usr/bin/env sh curl \ --location \ ---request POST 'localhost:3003/locations' \ +--request POST 'localhost:7000/catalog/locations' \ --header 'Content-Type: application/json' \ --data-raw '{ "type": "github", diff --git a/plugins/catalog-backend/src/database/CommonDatabase.ts b/plugins/catalog-backend/src/database/CommonDatabase.ts index dd965c89ef..bb5b95d9ba 100644 --- a/plugins/catalog-backend/src/database/CommonDatabase.ts +++ b/plugins/catalog-backend/src/database/CommonDatabase.ts @@ -19,7 +19,12 @@ import { InputError, NotFoundError, } from '@backstage/backend-common'; -import type { Entity, EntityMeta, Location } from '@backstage/catalog-model'; +import { + Entity, + EntityMeta, + Location, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; import Knex from 'knex'; import lodash from 'lodash'; import { v4 as uuidv4 } from 'uuid'; @@ -167,7 +172,7 @@ export class CommonDatabase implements Database { annotations: { ...(newEntity.metadata?.annotations ?? {}), ...(request.locationId - ? { 'backstage.io/managed-by-location': request.locationId } + ? { [LOCATION_ANNOTATION]: request.locationId } : {}), }, }; diff --git a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts index e9051d1f78..3300df1640 100644 --- a/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts +++ b/plugins/catalog-backend/src/ingestion/HigherOrderOperations.ts @@ -15,7 +15,12 @@ */ import { InputError } from '@backstage/backend-common'; -import { Entity, Location, LocationSpec } from '@backstage/catalog-model'; +import { + Entity, + Location, + LocationSpec, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; import lodash from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { EntitiesCatalog, LocationsCatalog } from '../catalog'; @@ -23,8 +28,6 @@ import { IngestionModel } from '../ingestion'; import { AddLocationResult, HigherOrderOperation } from './types'; import { Logger } from 'winston'; -const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; - /** * Placeholder for operations that span several catalogs and/or stretches out * in time. diff --git a/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts index 1f0f6ed539..56120fab23 100644 --- a/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts +++ b/plugins/catalog-backend/src/ingestion/source/readers/GitHubLocationReader.test.ts @@ -81,6 +81,8 @@ describe('Integration: GitHubLocationSource', () => { }); it('fetches the fixture from backstage repo', async () => { + (fetch as any).mockResolvedValueOnce(new Response('component3')); + const PERMANENT_LINK = 'https://github.com/spotify/backstage/blob/ee84a874f8e37f87940cbe515a86c07a2db29541/plugins/catalog-backend/fixtures/one_component.yaml'; diff --git a/plugins/catalog-backend/src/run.ts b/plugins/catalog-backend/src/run.ts index f4845168aa..b96989e4b8 100644 --- a/plugins/catalog-backend/src/run.ts +++ b/plugins/catalog-backend/src/run.ts @@ -18,7 +18,7 @@ import { getRootLogger } from '@backstage/backend-common'; import yn from 'yn'; import { startStandaloneServer } from './service/standaloneServer'; -const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 3003; +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7000; const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); const logger = getRootLogger(); diff --git a/plugins/catalog-backend/src/service/standaloneApplication.ts b/plugins/catalog-backend/src/service/standaloneApplication.ts index bdf296d06b..e8c1a226f0 100644 --- a/plugins/catalog-backend/src/service/standaloneApplication.ts +++ b/plugins/catalog-backend/src/service/standaloneApplication.ts @@ -56,7 +56,7 @@ export async function createStandaloneApplication( app.use(express.json()); app.use(requestLoggingHandler()); app.use( - '/', + '/catalog', await createRouter({ entitiesCatalog, locationsCatalog, diff --git a/plugins/catalog/src/api/CatalogClient.ts b/plugins/catalog/src/api/CatalogClient.ts index 4ae4676703..2f9ffe302c 100644 --- a/plugins/catalog/src/api/CatalogClient.ts +++ b/plugins/catalog/src/api/CatalogClient.ts @@ -16,7 +16,11 @@ import { CatalogApi } from './types'; import { DescriptorEnvelope } from '../types'; -import { Entity, Location } from '@backstage/catalog-model'; +import { + Entity, + Location, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; export class CatalogClient implements CatalogApi { private apiOrigin: string; @@ -31,6 +35,22 @@ export class CatalogClient implements CatalogApi { this.apiOrigin = apiOrigin; this.basePath = basePath; } + async getLocationById(id: String): Promise { + const response = await fetch( + `${this.apiOrigin}${this.basePath}/locations/${id}`, + ); + if (response.ok) { + const location = await response.json(); + if (location) return location.data; + } + return undefined; + } + async getEntitiesByLocationId(id: string): Promise { + const response = await fetch( + `${this.apiOrigin}${this.basePath}/entities?${LOCATION_ANNOTATION}=${id}`, + ); + return await response.json(); + } async getEntities(): Promise { const response = await fetch(`${this.apiOrigin}${this.basePath}/entities`); return await response.json(); @@ -72,20 +92,11 @@ export class CatalogClient implements CatalogApi { } async getLocationByEntity(entity: Entity): Promise { - const findLocationIdInEntity = (e: Entity): string | undefined => - e.metadata.annotations?.['backstage.io/managed-by-location']; - - const locationId = findLocationIdInEntity(entity); + const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION]; if (!locationId) return undefined; - const response = await fetch( - `${this.apiOrigin}${this.basePath}/locations/${locationId}`, - ); - if (response.ok) { - const location = await response.json(); - if (location) return location.data; - } + const location = this.getLocationById(locationId); - return undefined; + return location; } } diff --git a/plugins/catalog/src/api/types.ts b/plugins/catalog/src/api/types.ts index 5bb6a6ca83..7d6c1d2b47 100644 --- a/plugins/catalog/src/api/types.ts +++ b/plugins/catalog/src/api/types.ts @@ -23,8 +23,10 @@ export const catalogApiRef = createApiRef({ }); export interface CatalogApi { + getLocationById(id: String): Promise; getEntities(): Promise; getEntityByName(name: string): Promise; + getEntitiesByLocationId(id: string): Promise; addLocation(type: string, target: string): Promise; getLocationByEntity(entity: Entity): Promise; } diff --git a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx index 779bcc7693..84c35e406a 100644 --- a/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx +++ b/plugins/catalog/src/components/CatalogPage/CatalogPage.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React, { FC, useCallback, useState, useEffect } from 'react'; +import React, { FC, useCallback, useState } from 'react'; import { Content, ContentHeader, @@ -26,7 +26,7 @@ import { pageTheme, useApi, } from '@backstage/core'; -import { useAsync, useMountedState } from 'react-use'; +import { useAsync } from 'react-use'; import CatalogTable from '../CatalogTable/CatalogTable'; import { CatalogFilter, @@ -37,7 +37,11 @@ import { filterGroups, defaultFilter } from '../../data/filters'; import { Link as RouterLink } from 'react-router-dom'; import { rootRoute as scaffolderRootRoute } from '@backstage/plugin-scaffolder'; import GitHub from '@material-ui/icons/GitHub'; -import { Entity, Location } from '@backstage/catalog-model'; +import { + Entity, + Location, + LOCATION_ANNOTATION, +} from '@backstage/catalog-model'; const useStyles = makeStyles(theme => ({ contentWrapper: { @@ -49,17 +53,15 @@ const useStyles = makeStyles(theme => ({ })); import { catalogApiRef } from '../..'; -import { envelopeToComponent } from '../../data/utils'; +import { entityToComponent, findLocationForEntity } from '../../data/utils'; import { Component } from '../../data/component'; const CatalogPage: FC<{}> = () => { const catalogApi = useApi(catalogApiRef); const { value, error, loading } = useAsync(() => catalogApi.getEntities()); - const [locations, setLocations] = useState([]); const [selectedFilter, setSelectedFilter] = useState( defaultFilter, ); - const isMounted = useMountedState(); const onFilterSelected = useCallback( selected => setSelectedFilter(selected), @@ -67,25 +69,26 @@ const CatalogPage: FC<{}> = () => { ); const styles = useStyles(); - useEffect(() => { + const { value: locations } = useAsync(async () => { const getLocationDataForEntities = async (entities: Entity[]) => { return Promise.all( - entities.map(entity => catalogApi.getLocationByEntity(entity)), + entities.map(entity => { + const locationId = entity.metadata.annotations?.[LOCATION_ANNOTATION]; + if (!locationId) return undefined; + + return catalogApi.getLocationById(locationId); + }), ); }; if (value) { - getLocationDataForEntities(value) - .then( - (location): Location[] => - location.filter(l => !!l) as Array, - ) - .then(location => { - if (isMounted()) setLocations(location); - }); + return getLocationDataForEntities(value).then( + (location): Location[] => + location.filter(loc => !!loc) as Array, + ); } - }, [value, catalogApi, isMounted]); - + return []; + }, [value, catalogApi, catalogApi]); const actions = [ (rowData: Component) => ({ icon: GitHub, @@ -98,16 +101,6 @@ const CatalogPage: FC<{}> = () => { rowData && rowData.location ? rowData.location.type !== 'github' : true, }), ]; - - const findLocationForEntity = ( - entity: Entity, - l: Location[], - ): Location | undefined => { - const entityLocationId = - entity.metadata.annotations?.['backstage.io/managed-by-location']; - return l.find(location => location.id === entityLocationId); - }; - return (
@@ -149,22 +142,24 @@ const CatalogPage: FC<{}> = () => { onSelectedChange={onFilterSelected} /> - - envelopeToComponent( - val, - findLocationForEntity(val, locations), - ), - )) || - [] - } - loading={loading} - error={error} - actions={actions} - /> + {locations && ( + { + return { + ...entityToComponent(val), + location: findLocationForEntity(val, locations), + }; + })) || + [] + } + loading={loading} + error={error} + actions={actions} + /> + )} diff --git a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx index 62f34f1620..3762cc8345 100644 --- a/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx +++ b/plugins/catalog/src/components/ComponentPage/ComponentPage.tsx @@ -30,7 +30,8 @@ import ComponentRemovalDialog from '../ComponentRemovalDialog/ComponentRemovalDi import { SentryIssuesWidget } from '@backstage/plugin-sentry'; import { Grid } from '@material-ui/core'; import { catalogApiRef } from '../..'; -import { envelopeToComponent } from '../../data/utils'; +import { entityToComponent } from '../../data/utils'; +import { Component } from '../../data/component'; const REDIRECT_DELAY = 1000; @@ -54,18 +55,20 @@ const ComponentPage: FC = ({ match, history }) => { const errorApi = useApi(errorApiRef); const catalogApi = useApi(catalogApiRef); - const catalogRequest = useAsync(() => - catalogApi.getEntityByName(match.params.name), - ); + const { value: component, error, loading } = useAsync(async () => { + const entity = await catalogApi.getEntityByName(match.params.name); + const location = await catalogApi.getLocationByEntity(entity); + return { ...entityToComponent(entity), location }; + }); useEffect(() => { - if (catalogRequest.error) { + if (error) { errorApi.post(new Error('Component not found!')); setTimeout(() => { history.push('/catalog'); }, REDIRECT_DELAY); } - }, [catalogRequest.error, errorApi, history]); + }, [error, errorApi, history]); if (componentName === '') { history.push('/catalog'); @@ -76,17 +79,16 @@ const ComponentPage: FC = ({ match, history }) => { setConfirmationDialogOpen(false); setRemovingPending(true); // await componentFactory.removeComponentByName(componentName); + await catalogApi; history.push('/catalog'); }; - const component = envelopeToComponent(catalogRequest.value! ?? {}); - return ( -
+
- {confirmationDialogOpen && catalogRequest.value && ( + {confirmationDialogOpen && component && ( = ({ match, history }) => { diff --git a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx index b5a863cef8..7dde95c8f0 100644 --- a/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx +++ b/plugins/catalog/src/components/ComponentRemovalDialog/ComponentRemovalDialog.tsx @@ -25,6 +25,10 @@ import { useTheme, } from '@material-ui/core'; import { Component } from '../../data/component'; +import { useAsync } from 'react-use'; +import { useApi } from '@backstage/core'; +import { catalogApiRef } from '../../api/types'; +import { Entity } from '@backstage/catalog-model'; type ComponentRemovalDialogProps = { onConfirm: () => any; @@ -38,18 +42,28 @@ const ComponentRemovalDialog: FC = ({ onClose, component, }) => { + const catalogApi = useApi(catalogApiRef); + const { value } = useAsync(async () => { + let colocatedEntities: Array = []; + const locationId = component.location?.id; + if (locationId) { + colocatedEntities = await catalogApi.getEntitiesByLocationId(locationId); + } + return colocatedEntities; + }); const theme = useTheme(); const fullScreen = useMediaQuery(theme.breakpoints.down('sm')); + const infoMessage = `This action will unregister ${ + value ? value.map(e => e.metadata.name).join(', ') : '' + } from location with target ${component.location?.target}. To undo, + just re-register the component in Backstage.`; return ( Are you sure you want to unregister this component? - - This action will unregister {component.name}. To undo, just - re-register the component in Backstage. - + {infoMessage}