Merge pull request #4502 from adamdmharvey/system-diagram
catalog: Add new System Diagram card
This commit is contained in:
@@ -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 & 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';
|
||||
@@ -29,4 +29,5 @@ export {
|
||||
EntityHasSubcomponentsCard,
|
||||
EntityHasSystemsCard,
|
||||
EntityLinksCard,
|
||||
EntitySystemDiagramCard,
|
||||
} from './plugin';
|
||||
|
||||
@@ -114,3 +114,12 @@ export const EntityHasSubcomponentsCard = catalogPlugin.provide(
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const EntitySystemDiagramCard = catalogPlugin.provide(
|
||||
createComponentExtension({
|
||||
component: {
|
||||
lazy: () =>
|
||||
import('./components/SystemDiagramCard').then(m => m.SystemDiagramCard),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user