Merge pull request #4502 from adamdmharvey/system-diagram

catalog: Add new System Diagram card
This commit is contained in:
Adam Harvey
2021-03-26 10:24:55 -04:00
committed by GitHub
7 changed files with 345 additions and 0 deletions
+26
View File
@@ -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 }) => (
<EntityPageLayoutWrapper>
<EntityPageLayout.Content
path="/*"
title="Overview"
element={<SystemOverviewContent entity={entity} />}
/>
+ <EntityPageLayout.Content
+ path="/diagram/*"
+ title="Diagram"
+ element={<EntitySystemDiagramCard />}
+ />
</EntityPageLayoutWrapper>
);
```
@@ -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={<SystemOverviewContent entity={entity as SystemEntity} />}
/>
<EntityPageLayout.Content
path="/diagram/*"
title="Diagram"
element={<EntitySystemDiagramCard />}
/>
</EntityPageLayoutWrapper>
);
@@ -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('<SystemDiagramCard />', () => {
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<CatalogApi> = {
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(
<ApiProvider apis={ApiRegistry.from([[catalogApiRef, catalogApi]])}>
<EntityProvider entity={entity}>
<SystemDiagramCard />
</EntityProvider>
</ApiProvider>,
);
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<CatalogApi> = {
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(
<ApiProvider apis={ApiRegistry.from([[catalogApiRef, catalogApi]])}>
<EntityProvider entity={entity}>
<SystemDiagramCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('System Diagram')).toBeInTheDocument();
expect(getByText('system:my-namespace/my-system')).toBeInTheDocument();
expect(getByText('component:my-namespace/my-entity')).toBeInTheDocument();
});
});
@@ -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 <Progress />;
} else if (error) {
return <ResponseErrorPanel error={error} />;
}
return (
<InfoCard title="System Diagram">
<DependencyGraph
nodes={systemNodes}
edges={systemEdges}
nodeMargin={10}
direction={DependencyGraphTypes.Direction.BOTTOM_TOP}
/>
<Box m={1} />
<Typography
variant="caption"
style={{ display: 'block', textAlign: 'right' }}
>
<ZoomOutMap style={{ verticalAlign: 'bottom' }} /> Use pinch &amp; zoom
to move around the diagram.
</Typography>
</InfoCard>
);
}
@@ -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';
+1
View File
@@ -29,4 +29,5 @@ export {
EntityHasSubcomponentsCard,
EntityHasSystemsCard,
EntityLinksCard,
EntitySystemDiagramCard,
} from './plugin';
+9
View File
@@ -114,3 +114,12 @@ export const EntityHasSubcomponentsCard = catalogPlugin.provide(
},
}),
);
export const EntitySystemDiagramCard = catalogPlugin.provide(
createComponentExtension({
component: {
lazy: () =>
import('./components/SystemDiagramCard').then(m => m.SystemDiagramCard),
},
}),
);