Merge pull request #1160 from spotify/ndudnik/unregister-component
Unregister component: front-end
This commit is contained in:
@@ -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,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';
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+2
@@ -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 = () => ({
|
||||
|
||||
Reference in New Issue
Block a user