diff --git a/.changeset/perfect-coats-train.md b/.changeset/perfect-coats-train.md new file mode 100644 index 0000000000..9357f51370 --- /dev/null +++ b/.changeset/perfect-coats-train.md @@ -0,0 +1,26 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Adds a new `EntitySystemDiagramCard` component to visually map all elements in a system. + +To use this new component with the legacy composability pattern, you can add a new tab with the component on to the System Entity Page in your `packages/app/src/components/catalog/EntityPage.tsx` file. + +For example, + +```diff + const SystemEntityPage = ({ entity }: { entity: Entity }) => ( + + } + /> ++ } ++ /> + + ); +``` diff --git a/packages/app/src/components/catalog/EntityPage.tsx b/packages/app/src/components/catalog/EntityPage.tsx index 0f9cc98f4b..47bb9ef09f 100644 --- a/packages/app/src/components/catalog/EntityPage.tsx +++ b/packages/app/src/components/catalog/EntityPage.tsx @@ -39,6 +39,7 @@ import { EntityHasSystemsCard, EntityLinksCard, EntityPageLayout, + EntitySystemDiagramCard, } from '@backstage/plugin-catalog'; import { EntityProvider, useEntity } from '@backstage/plugin-catalog-react'; import { @@ -501,6 +502,11 @@ const SystemEntityPage = ({ entity }: { entity: Entity }) => ( title="Overview" element={} /> + } + /> ); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx new file mode 100644 index 0000000000..baa8c667d2 --- /dev/null +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.test.tsx @@ -0,0 +1,124 @@ +/* + * 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 { ApiProvider, ApiRegistry } from '@backstage/core'; +import { + catalogApiRef, + CatalogApi, + EntityProvider, +} from '@backstage/plugin-catalog-react'; +import { Entity, RELATION_PART_OF } from '@backstage/catalog-model'; +import { renderInTestApp } from '@backstage/test-utils'; +import React from 'react'; +import { SystemDiagramCard } from './SystemDiagramCard'; + +describe('', () => { + beforeAll(() => { + Object.defineProperty(window.SVGElement.prototype, 'getBBox', { + value: () => ({ width: 100, height: 100 }), + configurable: true, + }); + }); + + afterEach(() => jest.resetAllMocks()); + + it('shows empty list if no relations', async () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [] as Entity[], + }), + }; + + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system2', + namespace: 'my-namespace2', + }, + relations: [], + }; + + const { queryByText } = await renderInTestApp( + + + + + , + ); + + expect(queryByText(/System Diagram/)).toBeInTheDocument(); + expect(queryByText(/system:my-namespace2\/my-system2/)).toBeInTheDocument(); + expect( + queryByText(/component:my-namespace\/my-entity/), + ).not.toBeInTheDocument(); + }); + + it('shows related systems', async () => { + const catalogApi: Partial = { + getEntities: () => + Promise.resolve({ + items: [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-entity', + namespace: 'my-namespace', + }, + spec: { + owner: 'not-tools@example.com', + type: 'service', + system: 'my-system', + }, + }, + ] as Entity[], + }), + }; + + const entity: Entity = { + apiVersion: 'v1', + kind: 'System', + metadata: { + name: 'my-system', + namespace: 'my-namespace', + }, + relations: [ + { + target: { + kind: 'Domain', + namespace: 'my-namespace', + name: 'my-domain', + }, + type: RELATION_PART_OF, + }, + ], + }; + + const { getByText } = await renderInTestApp( + + + + + , + ); + + expect(getByText('System Diagram')).toBeInTheDocument(); + expect(getByText('system:my-namespace/my-system')).toBeInTheDocument(); + expect(getByText('component:my-namespace/my-entity')).toBeInTheDocument(); + }); +}); diff --git a/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx new file mode 100644 index 0000000000..df20e910aa --- /dev/null +++ b/plugins/catalog/src/components/SystemDiagramCard/SystemDiagramCard.tsx @@ -0,0 +1,162 @@ +/* + * Copyright 2021 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 { + Entity, + RELATION_PROVIDES_API, + RELATION_PART_OF, + serializeEntityRef, +} from '@backstage/catalog-model'; +import { + catalogApiRef, + getEntityRelations, + useEntity, +} from '@backstage/plugin-catalog-react'; +import { + DependencyGraph, + DependencyGraphTypes, + InfoCard, + Progress, + useApi, + ResponseErrorPanel, +} from '@backstage/core'; +import { Box, Typography } from '@material-ui/core'; +import ZoomOutMap from '@material-ui/icons/ZoomOutMap'; +import React from 'react'; +import { useAsync } from 'react-use'; + +function simplifiedEntityName( + ref: + | Entity + | { + kind?: string; + namespace?: string; + name: string; + }, +): string { + // Simplify the diagram output by hiding only the default namespace + return serializeEntityRef(ref) + .toString() + .toLocaleLowerCase('en-US') + .replace(':default/', ':'); +} + +/** + * Dynamically generates a diagram of a system, its assigned entities, + * and relationships of those entities. + */ +export function SystemDiagramCard() { + const { entity } = useEntity(); + const currentSystemName = entity.metadata.name; + const currentSystemNode = simplifiedEntityName(entity); + const systemNodes = new Array<{ id: string }>(); + const systemEdges = new Array<{ from: string; to: string; label: string }>(); + + const catalogApi = useApi(catalogApiRef); + const { loading, error, value: catalogResponse } = useAsync(() => { + return catalogApi.getEntities({ + filter: { + kind: ['Component', 'API', 'Resource', 'System', 'Domain'], + 'spec.system': currentSystemName, + }, + }); + }, [catalogApi, currentSystemName]); + + // pick out the system itself + systemNodes.push({ + id: currentSystemNode, + }); + + // check if the system has an assigned domain + // even if the domain object doesn't exist in the catalog, display it in the map + const catalogItemDomain = getEntityRelations(entity, RELATION_PART_OF, { + kind: 'domain', + }); + catalogItemDomain.forEach(foundDomain => + systemNodes.push({ + id: simplifiedEntityName(foundDomain), + }), + ); + catalogItemDomain.forEach(foundDomain => + systemEdges.push({ + from: currentSystemNode, + to: simplifiedEntityName(foundDomain), + label: 'part of', + }), + ); + + if (catalogResponse && catalogResponse.items) { + for (const catalogItem of catalogResponse.items) { + systemNodes.push({ + id: simplifiedEntityName(catalogItem), + }); + + // Check relations of the entity assigned to this system to see + // if it relates to other entities. + // Note those relations may, or may not, be explicitly + // assigned to the system. + const catalogItemRelations_partOf = getEntityRelations( + catalogItem, + RELATION_PART_OF, + ); + catalogItemRelations_partOf.forEach(foundRelation => + systemEdges.push({ + from: simplifiedEntityName(catalogItem), + to: simplifiedEntityName(foundRelation), + label: 'part of', + }), + ); + + const catalogItemRelations_providesApi = getEntityRelations( + catalogItem, + RELATION_PROVIDES_API, + ); + + catalogItemRelations_providesApi.forEach(foundRelation => + systemEdges.push({ + from: simplifiedEntityName(catalogItem), + to: simplifiedEntityName(foundRelation), + label: 'provides API', + }), + ); + } + } + + if (loading) { + return ; + } else if (error) { + return ; + } + + return ( + + + + + Use pinch & zoom + to move around the diagram. + + + ); +} diff --git a/plugins/catalog/src/components/SystemDiagramCard/index.ts b/plugins/catalog/src/components/SystemDiagramCard/index.ts new file mode 100644 index 0000000000..6d86b31b68 --- /dev/null +++ b/plugins/catalog/src/components/SystemDiagramCard/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2021 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 { SystemDiagramCard } from './SystemDiagramCard'; diff --git a/plugins/catalog/src/index.ts b/plugins/catalog/src/index.ts index 800c5c4693..7ab7b95dd7 100644 --- a/plugins/catalog/src/index.ts +++ b/plugins/catalog/src/index.ts @@ -29,4 +29,5 @@ export { EntityHasSubcomponentsCard, EntityHasSystemsCard, EntityLinksCard, + EntitySystemDiagramCard, } from './plugin'; diff --git a/plugins/catalog/src/plugin.ts b/plugins/catalog/src/plugin.ts index 19dc778280..20c7a865db 100644 --- a/plugins/catalog/src/plugin.ts +++ b/plugins/catalog/src/plugin.ts @@ -114,3 +114,12 @@ export const EntityHasSubcomponentsCard = catalogPlugin.provide( }, }), ); + +export const EntitySystemDiagramCard = catalogPlugin.provide( + createComponentExtension({ + component: { + lazy: () => + import('./components/SystemDiagramCard').then(m => m.SystemDiagramCard), + }, + }), +);