Merge pull request #1160 from spotify/ndudnik/unregister-component

Unregister component: front-end
This commit is contained in:
Nikita Dudnik
2020-06-05 23:05:29 +02:00
committed by GitHub
16 changed files with 152 additions and 82 deletions
@@ -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';
@@ -16,3 +16,4 @@
export type { Location, LocationSpec } from './types';
export { locationSchema, locationSpecSchema } from './validation';
export { LOCATION_ANNOTATION } from './annotation';
+1 -1
View File
@@ -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",
@@ -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 }
: {}),
},
};
@@ -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.
@@ -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';
+1 -1
View File
@@ -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();
@@ -56,7 +56,7 @@ export async function createStandaloneApplication(
app.use(express.json());
app.use(requestLoggingHandler());
app.use(
'/',
'/catalog',
await createRouter({
entitiesCatalog,
locationsCatalog,
+24 -13
View File
@@ -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<Location | undefined> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/locations/${id}`,
);
if (response.ok) {
const location = await response.json();
if (location) return location.data;
}
return undefined;
}
async getEntitiesByLocationId(id: string): Promise<Entity[]> {
const response = await fetch(
`${this.apiOrigin}${this.basePath}/entities?${LOCATION_ANNOTATION}=${id}`,
);
return await response.json();
}
async getEntities(): Promise<DescriptorEnvelope[]> {
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<Location | undefined> {
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;
}
}
+2
View File
@@ -23,8 +23,10 @@ export const catalogApiRef = createApiRef<CatalogApi>({
});
export interface CatalogApi {
getLocationById(id: String): Promise<Location | undefined>;
getEntities(): Promise<Entity[]>;
getEntityByName(name: string): Promise<Entity>;
getEntitiesByLocationId(id: string): Promise<Entity[]>;
addLocation(type: string, target: string): Promise<AddLocationResponse>;
getLocationByEntity(entity: Entity): Promise<Location | undefined>;
}
@@ -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<Location[]>([]);
const [selectedFilter, setSelectedFilter] = useState<CatalogFilterItem>(
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<Location>,
)
.then(location => {
if (isMounted()) setLocations(location);
});
return getLocationDataForEntities(value).then(
(location): Location[] =>
location.filter(loc => !!loc) as Array<Location>,
);
}
}, [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 (
<Page theme={pageTheme.home}>
<Header title="Service Catalog" subtitle="Keep track of your software">
@@ -149,22 +142,24 @@ const CatalogPage: FC<{}> = () => {
onSelectedChange={onFilterSelected}
/>
</div>
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val =>
envelopeToComponent(
val,
findLocationForEntity(val, locations),
),
)) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
{locations && (
<CatalogTable
titlePreamble={selectedFilter.label}
components={
(value &&
value.map(val => {
return {
...entityToComponent(val),
location: findLocationForEntity(val, locations),
};
})) ||
[]
}
loading={loading}
error={error}
actions={actions}
/>
)}
</div>
</Content>
</Page>
@@ -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<ComponentPageProps> = ({ match, history }) => {
const errorApi = useApi<ErrorApi>(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const catalogRequest = useAsync(() =>
catalogApi.getEntityByName(match.params.name),
);
const { value: component, error, loading } = useAsync<Component>(async () => {
const entity = await catalogApi.getEntityByName(match.params.name);
const location = await catalogApi.getLocationByEntity(entity);
return { ...entityToComponent(entity), location };
});
useEffect(() => {
if (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<ComponentPageProps> = ({ match, history }) => {
setConfirmationDialogOpen(false);
setRemovingPending(true);
// await componentFactory.removeComponentByName(componentName);
await catalogApi;
history.push('/catalog');
};
const component = envelopeToComponent(catalogRequest.value! ?? {});
return (
<Page theme={pageTheme.home}>
<Header title={component.name || 'Catalog'}>
<Header title={component?.name || 'Catalog'}>
<ComponentContextMenu onUnregisterComponent={showRemovalDialog} />
</Header>
{confirmationDialogOpen && catalogRequest.value && (
{confirmationDialogOpen && component && (
<ComponentRemovalDialog
component={component}
onClose={hideRemovalDialog}
@@ -98,7 +100,7 @@ const ComponentPage: FC<ComponentPageProps> = ({ match, history }) => {
<Grid container spacing={3} direction="column">
<Grid item>
<ComponentMetadataCard
loading={catalogRequest.loading || removingPending}
loading={loading || removingPending}
component={component}
/>
</Grid>
@@ -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<ComponentRemovalDialogProps> = ({
onClose,
component,
}) => {
const catalogApi = useApi(catalogApiRef);
const { value } = useAsync(async () => {
let colocatedEntities: Array<Entity> = [];
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 (
<Dialog fullScreen={fullScreen} open onClose={onClose}>
<DialogTitle id="responsive-dialog-title">
Are you sure you want to unregister this component?
</DialogTitle>
<DialogContent>
<DialogContentText>
This action will unregister {component.name}. To undo, just
re-register the component in Backstage.
</DialogContentText>
<DialogContentText>{infoMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={onCancel} color="primary">
+1
View File
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from 'react';
import { Location } from '@backstage/catalog-model';
+18 -2
View File
@@ -15,7 +15,11 @@
*/
import React from 'react';
import { Component } from './component';
import { Entity, Location } from '@backstage/catalog-model';
import {
Entity,
Location,
LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import Edit from '@material-ui/icons/Edit';
import IconButton from '@material-ui/core/IconButton';
import { styled } from '@material-ui/core/styles';
@@ -27,7 +31,7 @@ const DescriptionWrapper = styled('span')({
const createEditLink = (url: string): string => url.replace('blob', 'edit');
export function envelopeToComponent(
export function entityToComponent(
envelope: Entity,
location?: Location,
): Component {
@@ -49,3 +53,15 @@ export function envelopeToComponent(
location,
};
}
export function findLocationForEntity(
entity: Entity,
locations: Location[],
): Location | undefined {
for (const loc of locations) {
if (loc.id === entity.metadata.annotations?.[LOCATION_ANNOTATION]) {
return loc;
}
}
return undefined;
}
@@ -30,6 +30,8 @@ const catalogApi: jest.Mocked<typeof catalogApiRef.T> = {
getEntities: jest.fn(),
getEntityByName: jest.fn(),
getLocationByEntity: jest.fn(),
getLocationById: jest.fn(),
getEntitiesByLocationId: jest.fn(),
};
const setup = () => ({