Fixed "simplified" in the catalog-graph, solving #30121

Signed-off-by: Gustaf Räntilä <g.rantila@gmail.com>
This commit is contained in:
Gustaf Räntilä
2025-09-05 09:19:02 +02:00
parent 3eeb8d395c
commit b9a87f4593
36 changed files with 738 additions and 219 deletions
+1
View File
@@ -3,3 +3,4 @@
---
Support custom relations by using an API to define known relations and which to show by default
Fixes "simplified" bug (#30121) which caused graphs not to be simplified
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': minor
---
Add optional `distance` property to `DependencyEdge` to reflect the distance to a root.
+1
View File
@@ -291,6 +291,7 @@ export namespace DependencyGraphTypes {
from: string;
to: string;
label?: string;
distance?: number;
};
export type DependencyNode<T = {}> = T & {
id: string;
@@ -46,6 +46,10 @@ export namespace DependencyGraphTypes {
* Label assigned and rendered with the Edge
*/
label?: string;
/**
* Distance to a root entity
*/
distance?: number;
};
/**
+2 -2
View File
@@ -84,9 +84,9 @@ const _default: FrontendPlugin<
relations?: string[] | undefined;
maxDepth?: number | undefined;
kinds?: string[] | undefined;
unidirectional?: boolean | undefined;
mergeRelations?: boolean | undefined;
relationPairs?: [string, string][] | undefined;
unidirectional?: boolean | undefined;
} & {
filter?: EntityPredicate | undefined;
type?: 'content' | 'summary' | 'info' | undefined;
@@ -157,9 +157,9 @@ const _default: FrontendPlugin<
rootEntityRefs?: string[] | undefined;
maxDepth?: number | undefined;
kinds?: string[] | undefined;
unidirectional?: boolean | undefined;
mergeRelations?: boolean | undefined;
relationPairs?: [string, string][] | undefined;
unidirectional?: boolean | undefined;
selectedRelations?: string[] | undefined;
selectedKinds?: string[] | undefined;
showFilters?: boolean | undefined;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../types';
import { ALL_RELATION_PAIRS, ALL_RELATIONS, RelationPairs } from '../lib/types';
import {
CatalogGraphApi,
DefaultRelations,
@@ -32,13 +32,12 @@ import { MouseEvent, ReactNode, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { catalogGraphRouteRef } from '../../routes';
import {
Direction,
EntityNode,
EntityRelationsGraph,
EntityRelationsGraphProps,
} from '../EntityRelationsGraph';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
import { Direction, EntityNode } from '../../lib/types';
/** @public */
export type CatalogGraphCardClassKey = 'card' | 'graph';
@@ -37,8 +37,6 @@ import ToggleButton from '@material-ui/lab/ToggleButton';
import { MouseEvent, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Direction,
EntityNode,
EntityRelationsGraph,
EntityRelationsGraphProps,
} from '../EntityRelationsGraph';
@@ -51,6 +49,7 @@ import { SwitchFilter } from './SwitchFilter';
import { useCatalogGraphPage } from './useCatalogGraphPage';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
import { Direction, EntityNode } from '../../lib/types';
/** @public */
export type CatalogGraphPageClassKey =
@@ -16,7 +16,7 @@
import { waitFor, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { DirectionFilter } from './DirectionFilter';
import { renderInTestApp } from '@backstage/test-utils';
@@ -16,7 +16,7 @@
import { Select, SelectedItems } from '@backstage/core-components';
import Box from '@material-ui/core/Box';
import { useCallback } from 'react';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
import { catalogGraphTranslationRef } from '../../translation';
@@ -23,7 +23,7 @@ import {
} from '@backstage/catalog-model';
import { waitFor, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ALL_RELATIONS } from '../../types';
import { ALL_RELATIONS } from '../../lib/types';
import { SelectedRelationsFilter } from './SelectedRelationsFilter';
import { renderInTestApp, TestApiRegistry } from '@backstage/test-utils';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
@@ -18,7 +18,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { ReactNode } from 'react';
import { act } from 'react-dom/test-utils';
import { BrowserRouter } from 'react-router-dom';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
import { useCatalogGraphPage } from './useCatalogGraphPage';
const wrapper = ({ children }: { children?: ReactNode }) => {
@@ -28,7 +28,7 @@ import {
useState,
} from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Direction } from '../EntityRelationsGraph';
import { Direction } from '../../lib/types';
export type CatalogGraphPageValue = {
rootEntityNames: CompoundEntityRef[];
@@ -15,7 +15,7 @@
*/
import { DependencyGraphTypes } from '@backstage/core-components';
import makeStyles from '@material-ui/core/styles/makeStyles';
import { EntityEdgeData } from './types';
import { EntityEdgeData } from '../../lib/types';
import classNames from 'classnames';
/** @public */
@@ -20,7 +20,7 @@ import { makeStyles } from '@material-ui/core/styles';
import classNames from 'classnames';
import { useLayoutEffect, useRef, useState } from 'react';
import { EntityIcon } from './EntityIcon';
import { EntityNodeData } from './types';
import { EntityNodeData } from '../../lib/types';
import { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
/** @public */
@@ -30,8 +30,8 @@ import classNames from 'classnames';
import { MouseEvent, useEffect, useMemo } from 'react';
import { DefaultRenderLabel } from './DefaultRenderLabel';
import { DefaultRenderNode } from './DefaultRenderNode';
import { RelationPairs } from '../../types';
import { Direction, EntityEdge, EntityNode } from './types';
import { RelationPairs } from '../../lib/types';
import { Direction, EntityEdge, EntityNode } from '../../lib/types';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
/** @public */
@@ -18,12 +18,5 @@ export type {
EntityRelationsGraphProps,
EntityRelationsGraphClassKey,
} from './EntityRelationsGraph';
export { Direction } from './types';
export type {
EntityEdgeData,
EntityEdge,
EntityNodeData,
EntityNode,
} from './types';
export type { CustomLabelClassKey } from './DefaultRenderLabel';
export type { CustomNodeClassKey } from './DefaultRenderNode';
@@ -106,13 +106,15 @@ export function useEntityRelationGraph({
requestEntities,
]);
const filteredEntities = useMemo(
() =>
entityFilter
? pickBy(entities, (value, _key) => entityFilter(value))
: entities,
[entities, entityFilter],
);
const filteredEntities = useMemo(() => {
if (loading) {
return {};
}
return entityFilter
? pickBy(entities, (value, _key) => entityFilter(value))
: entities;
}, [loading, entities, entityFilter]);
return {
entities: filteredEntities,
@@ -29,7 +29,7 @@ import { renderHook, waitFor } from '@testing-library/react';
import { filter, keyBy } from 'lodash';
import { useEntityRelationGraph as useEntityRelationGraphMocked } from './useEntityRelationGraph';
import { useEntityRelationNodesAndEdges } from './useEntityRelationNodesAndEdges';
import { EntityNode } from './types';
import { EntityNode } from '../../lib/types';
import { catalogGraphApiRef, DefaultCatalogGraphApi } from '../../api';
jest.mock('./useEntityRelationGraph');
@@ -256,24 +256,21 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
@@ -333,53 +330,26 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c',
},
{
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c2',
},
{
from: 'b:d/c2',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c1',
},
{
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
to: 'b:d/c',
},
{
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
to: 'b:d/c1',
},
]);
});
@@ -434,24 +404,28 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
to: 'k:d/a1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
@@ -511,48 +485,56 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c1',
},
{
distance: 1,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_OWNER_OF],
to: 'k:d/a1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART],
to: 'b:d/c2',
},
{
distance: 2,
from: 'b:d/c2',
label: 'visible',
relations: [RELATION_PART_OF],
to: 'b:d/c1',
},
{
distance: 1,
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
to: 'b:d/c',
},
{
distance: 2,
from: 'k:d/a1',
label: 'visible',
relations: [RELATION_OWNED_BY],
@@ -610,24 +592,21 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
from: 'b:d/c1',
distance: 1,
from: 'b:d/c2',
label: 'visible',
relations: ['hasPart', 'partOf'],
to: 'b:d/c2',
relations: ['partOf', 'hasPart'],
to: 'b:d/c1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: ['hasPart', 'partOf'],
to: 'b:d/c1',
},
{
from: 'b:d/c1',
label: 'visible',
relations: ['ownerOf', 'ownedBy'],
to: 'k:d/a1',
},
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: ['ownerOf', 'ownedBy'],
@@ -686,6 +665,7 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_OWNER_OF, RELATION_OWNED_BY],
@@ -738,12 +718,14 @@ describe('useEntityRelationNodesAndEdges', () => {
]);
expect(edges).toEqual([
{
distance: 1,
from: 'b:d/c',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
to: 'b:d/c1',
},
{
distance: 2,
from: 'b:d/c1',
label: 'visible',
relations: [RELATION_HAS_PART, RELATION_PART_OF],
@@ -13,13 +13,18 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { MouseEvent, useState } from 'react';
import { MouseEvent, useMemo, useState } from 'react';
import useDebounce from 'react-use/esm/useDebounce';
import { RelationPairs } from '../../types';
import { EntityEdge, EntityNode } from './types';
import { RelationPairs } from '../../lib/types';
import { EntityEdge, EntityNode } from '../../lib/types';
import { useEntityRelationGraph } from './useEntityRelationGraph';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { useRelations } from '../../hooks/useRelations';
import { buildGraph } from '../../lib/graph';
import {
builtInTransformers,
TransformerContext,
} from '../../lib/graph-transformations';
/**
* Generate nodes and edges to render the entity graph.
@@ -69,6 +74,11 @@ export function useEntityRelationNodesAndEdges({
relationPairs: incomingRelationPairs,
});
const forwardRelations = useMemo(
() => relationPairs.map(pair => pair[0]),
[relationPairs],
);
useDebounce(
() => {
if (!entities || Object.keys(entities).length === 0) {
@@ -98,141 +108,46 @@ export function useEntityRelationNodesAndEdges({
return node;
});
const edges: EntityEdge[] = [];
const visitedNodes = new Set<string>();
const nodeQueue = [...rootEntityRefs];
const edges = buildGraph({
rootEntityRefs,
entities,
includeRelation,
kinds,
mergeRelations,
relationPairs,
unidirectional,
});
const hasEdge = (
fromRef: string,
toRef: string,
relation: [string, string?],
): boolean => {
return edges.some(
edge =>
fromRef === edge.from &&
toRef === edge.to &&
edge.relations.some(
rel => rel[0] === relation[0] && rel[1] === relation[1],
),
);
const transformerContext: TransformerContext = {
nodeDistances: new Map(),
edges,
rootEntityRefs,
unidirectional,
maxDepth,
forwardRelations,
};
while (nodeQueue.length > 0) {
const entityRef = nodeQueue.pop()!;
const entity = entities[entityRef];
visitedNodes.add(entityRef);
if (entity) {
entity?.relations?.forEach(rel => {
// Check if the related entity should be displayed, if not, ignore
// the relation too
if (!entities[rel.targetRef]) {
return;
}
if (!includeRelation(rel.type)) {
return;
}
if (
kinds &&
!kinds.some(kind =>
rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`),
)
) {
return;
}
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
const [left] = pair;
const from = left === rel.type ? entityRef : rel.targetRef;
const to = left === rel.type ? rel.targetRef : entityRef;
if (!unidirectional || !hasEdge(from, to, pair)) {
edges.push({
from,
to,
relations: pair,
label: 'visible',
});
}
} else {
if (
!unidirectional ||
!hasEdge(entityRef, rel.targetRef, [rel.type])
) {
edges.push({
from: entityRef,
to: rel.targetRef,
relations: [rel.type],
label: 'visible',
});
}
}
if (!visitedNodes.has(rel.targetRef)) {
nodeQueue.push(rel.targetRef);
visitedNodes.add(rel.targetRef);
}
// if unidirectional add missing relations as entities are only visited once
if (unidirectional) {
const findIndex = edges.findIndex(
edge =>
entityRef === edge.from &&
rel.targetRef === edge.to &&
!edge.relations.includes(rel.type),
);
if (findIndex >= 0) {
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
edges[findIndex].relations = [
...edges[findIndex].relations,
...pair,
];
} else {
edges[findIndex].relations = [
...edges[findIndex].relations,
rel.type,
];
}
}
}
});
}
builtInTransformers['reduce-edges'](transformerContext);
builtInTransformers['set-distances'](transformerContext);
if (unidirectional) {
builtInTransformers['order-forward'](transformerContext);
builtInTransformers['strip-distant-edges'](transformerContext);
}
if (mergeRelations || unidirectional) {
// Merge relations even if only unidirectional, the next transformer
// 'remove-backward-edges' needs to know about all relations before it
// strips away the backward ones
builtInTransformers['merge-relations'](transformerContext);
}
if (unidirectional && !mergeRelations) {
builtInTransformers['remove-backward-edges'](transformerContext);
}
// Reduce edges as the dependency graph anyway ignores duplicated edges regarding from / to
// Additionally, this will improve rendering speed for the dependency graph
const finalEdges = edges.reduce((previousEdges, currentEdge) => {
const indexFound = previousEdges.findIndex(
previousEdge =>
previousEdge.from === currentEdge.from &&
previousEdge.to === currentEdge.to,
);
if (indexFound >= 0) {
previousEdges[indexFound] = {
...previousEdges[indexFound],
relations: Array.from(
new Set([
...previousEdges[indexFound].relations,
...currentEdge.relations,
]),
),
};
return previousEdges;
}
return [...previousEdges, currentEdge];
}, [] as EntityEdge[]);
setNodesAndEdges({ nodes, edges: finalEdges });
setNodesAndEdges({ nodes, edges: transformerContext.edges });
},
100,
[
maxDepth,
entities,
rootEntityRefs,
kinds,
@@ -241,6 +156,7 @@ export function useEntityRelationNodesAndEdges({
mergeRelations,
onNodeClick,
relationPairs,
forwardRelations,
],
);
@@ -16,7 +16,7 @@
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { Dispatch, useCallback, useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import useAsyncFn from 'react-use/esm/useAsyncFn';
// TODO: This is a good use case for a graphql API, once it is available in the
@@ -29,7 +29,7 @@ export function useEntityStore(): {
entities: { [ref: string]: Entity };
loading: boolean;
error?: Error;
requestEntities: Dispatch<string[]>;
requestEntities: (entityRefs: string[]) => void;
} {
const catalogClient = useApi(catalogApiRef);
const state = useRef({
@@ -19,7 +19,7 @@ import { useCallback, useMemo } from 'react';
import { useApi } from '@backstage/core-plugin-api';
import { catalogGraphApiRef } from '../api';
import { RelationPairs } from '../types';
import { RelationPairs } from '../lib/types';
export interface UseRelationsOptions {
relations?: string[];
@@ -30,6 +30,7 @@ export interface UseRelationsResult {
relations: string[];
relationPairs: RelationPairs;
defaultRelations: string[];
relationsToInclude: string[];
includeRelation: (type: string) => boolean;
}
@@ -48,15 +49,13 @@ export function useRelations(
const relations = opts.relations ?? knownRelations;
const relationPairs = opts.relationPairs ?? knownRelationPairs;
const relationsToInclude = opts.relations ?? defaultRelations;
const includeRelation = useCallback(
(type: string) => {
if (opts.relations) {
return opts.relations.includes(type);
}
return defaultRelations.includes(type);
return relationsToInclude.includes(type);
},
[opts.relations, defaultRelations],
[relationsToInclude],
);
return useMemo(
@@ -64,8 +63,15 @@ export function useRelations(
relations,
relationPairs,
defaultRelations,
relationsToInclude,
includeRelation,
}),
[relations, relationPairs, defaultRelations, includeRelation],
[
relations,
relationPairs,
defaultRelations,
relationsToInclude,
includeRelation,
],
);
}
+9 -2
View File
@@ -26,5 +26,12 @@ export { CatalogGraphPage, EntityCatalogGraphCard } from './extensions';
export * from './api';
export { catalogGraphPlugin } from './plugin';
export { catalogGraphRouteRef } from './routes';
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './types';
export type { RelationPairs } from './types';
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './lib/types';
export type {
RelationPairs,
EntityEdgeData,
EntityEdge,
EntityNodeData,
EntityNode,
} from './lib/types';
export { Direction } from './lib/types';
@@ -0,0 +1,33 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 type { GraphTransformer, TransformerContext } from './types';
import { reduceEdges } from './reduce-edges';
import { setDistances } from './set-distance';
import { orderForward } from './order-forward';
import { stripDistantEdges } from './strip-distant-edges';
import { mergeRelations } from './merge-relations';
import { removeBackwardEdges } from './remove-backward-edges';
export const builtInTransformers = {
'reduce-edges': reduceEdges,
'set-distances': setDistances,
'order-forward': orderForward,
'strip-distant-edges': stripDistantEdges,
'merge-relations': mergeRelations,
'remove-backward-edges': removeBackwardEdges,
};
@@ -0,0 +1,50 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
import { GraphTransformer } from './types';
function concatRelations(a: readonly string[], b: readonly string[]): string[] {
return Array.from(new Set<string>([...a, ...b]));
}
/** Merge relations in multiple edges into one edge */
export const mergeRelations: GraphTransformer = options => {
// The key is on the form `from-to`
const mergedEdges = new Map<string, EntityEdge>();
options.edges.forEach(edge => {
const keyForward = `${edge.from}-${edge.to}`;
const keyBackward = `${edge.to}-${edge.from}`;
const edgeForward = mergedEdges.get(keyForward);
const edgeBackward = mergedEdges.get(keyBackward);
if (edgeForward) {
edgeForward.relations = concatRelations(
edgeForward.relations,
edge.relations,
);
} else if (edgeBackward) {
edgeBackward.relations = concatRelations(
edge.relations,
edgeBackward.relations,
);
} else {
mergedEdges.set(keyForward, edge);
}
});
options.edges = Array.from(mergedEdges.values());
};
@@ -0,0 +1,33 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { GraphTransformer } from './types';
/** Orders the edges direction so that the graph goes strictly forward */
export const orderForward: GraphTransformer = ({ nodeDistances, edges }) => {
edges.forEach(edge => {
const fromDistance = nodeDistances.get(edge.from) ?? 0;
const toDistance = nodeDistances.get(edge.to) ?? 0;
if (toDistance < fromDistance) {
// Reverse order
const { from, to } = edge;
edge.from = to;
edge.to = from;
edge.relations.reverse();
}
});
};
@@ -0,0 +1,46 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
import { GraphTransformer } from './types';
/**
* Reduce edges as the dependency graph anyway ignores duplicated edges
* regarding from / to
* Additionally, this will improve rendering speed for the dependency graph
*/
export const reduceEdges: GraphTransformer = ctx => {
ctx.edges = ctx.edges.reduce((previousEdges, currentEdge) => {
const indexFound = previousEdges.findIndex(
previousEdge =>
previousEdge.from === currentEdge.from &&
previousEdge.to === currentEdge.to,
);
if (indexFound >= 0) {
previousEdges[indexFound] = {
...previousEdges[indexFound],
relations: Array.from(
new Set([
...previousEdges[indexFound].relations,
...currentEdge.relations,
]),
),
};
return previousEdges;
}
return [...previousEdges, currentEdge];
}, [] as EntityEdge[]);
};
@@ -0,0 +1,53 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
import { GraphTransformer } from './types';
/**
* Remove edges going backwards, if unidirectional and we haven't already
* merged the relations
*/
export const removeBackwardEdges: GraphTransformer = ctx => {
const { forwardRelations, edges } = ctx;
const edgesMapFrom = new Map<string, EntityEdge[]>();
edges.forEach(edge => {
const theseEdges = edgesMapFrom.get(edge.from) ?? [];
theseEdges.push(edge);
edgesMapFrom.set(edge.from, theseEdges);
});
ctx.edges = edges.filter(edge => {
if (edge.relations.length === 2) {
// If there are two relations, only keep the forward one
if (!forwardRelations.includes(edge.relations[1])) {
edge.relations.pop();
}
if (!forwardRelations.includes(edge.relations[0])) {
edge.relations.pop();
}
return edge.relations.length > 0;
} else if (edge.relations.length === 1) {
// If there is only one relation and it's backwards, remove it
if (!forwardRelations.includes(edge.relations[0])) {
return false;
}
}
return true;
});
};
@@ -0,0 +1,98 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
import { GraphTransformer } from './types';
function minDistance(a: number | undefined, b: number | undefined) {
if (typeof a === 'undefined') return b;
if (typeof b === 'undefined') return a;
return Math.min(a, b);
}
/** Set the distance of each edge to the distance from a root entity */
export const setDistances: GraphTransformer = ({
nodeDistances,
edges,
rootEntityRefs,
}) => {
const edgeMap = new Map<string, EntityEdge[]>();
edges.forEach(edge => {
const fromEdges = edgeMap.get(edge.from) ?? [];
edgeMap.set(edge.from, [...fromEdges, edge]);
const toEdges = edgeMap.get(edge.to) ?? [];
edgeMap.set(edge.to, [...toEdges, edge]);
});
// Sets the distance on as many edges as possible.
// Returns true if all edges have a distance
const setEdgeDistances = () => {
return (
edges.filter(edge => {
if (
rootEntityRefs.includes(edge.from) ||
rootEntityRefs.includes(edge.to)
) {
edge.distance = 1;
return false;
}
const distance = minDistance(
edgeMap
.get(edge.from)!
.reduce(
(prev, cur) => minDistance(prev, cur.distance),
undefined as number | undefined,
),
edgeMap
.get(edge.to)!
.reduce(
(prev, cur) => minDistance(prev, cur.distance),
undefined as number | undefined,
),
);
if (typeof distance !== 'undefined') {
edge.distance = minDistance(edge.distance, distance + 1);
}
return typeof edge.distance === 'undefined';
}).length === 0
);
};
let distanceComplete = false;
while (!distanceComplete) {
distanceComplete = setEdgeDistances();
}
rootEntityRefs.forEach(rootEntityRef => {
nodeDistances.set(rootEntityRef, 0);
});
edges.forEach(edge => {
const curFrom = nodeDistances.get(edge.from);
const curTo = nodeDistances.get(edge.to);
const fromDistance = minDistance(curFrom, edge.distance);
const toDistance = minDistance(curTo, edge.distance);
if (typeof fromDistance !== 'undefined') {
nodeDistances.set(edge.from, fromDistance);
}
if (typeof toDistance !== 'undefined') {
nodeDistances.set(edge.to, toDistance);
}
});
};
@@ -0,0 +1,78 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
import { GraphTransformer } from './types';
/**
* Removes edges that are further away then other edges leading up to the root.
*/
export const stripDistantEdges: GraphTransformer = options => {
const entityEdges = new Map<string, EntityEdge[]>();
options.edges.forEach(edge => {
let fromEdges = entityEdges.get(edge.from);
if (!fromEdges) {
fromEdges = [];
entityEdges.set(edge.from, fromEdges);
}
fromEdges.push(edge);
let toEdges = entityEdges.get(edge.to);
if (!toEdges) {
toEdges = [];
entityEdges.set(edge.to, toEdges);
}
toEdges.push(edge);
});
// Remove edges that are further away than other edges leading up to the root
options.edges = options.edges.filter(edge => {
// Get all edges of the 'from' node except this (and similar) edge
const fromEdges = entityEdges
.get(edge.from)!
.filter(
fromEdge =>
!(fromEdge.from === edge.from && fromEdge.to === edge.to) &&
!(fromEdge.from === edge.to && fromEdge.to === edge.from),
);
// Get all edges of the 'from' node except this (and similar) edge
const toEdges = entityEdges
.get(edge.to)!
.filter(
toEdge =>
!(toEdge.from === edge.from && toEdge.to === edge.to) &&
!(toEdge.from === edge.to && toEdge.to === edge.from),
);
if (fromEdges.length === 0 || toEdges.length === 0) {
return true;
}
const shorterFrom = fromEdges.some(
fromEdge => (fromEdge.distance ?? 0) < (edge.distance ?? 0),
);
const shorterTo = toEdges.some(
toEdge => (toEdge.distance ?? 0) < (edge.distance ?? 0),
);
if (shorterFrom && shorterTo) {
// There are shorter edges from both 'from' and 'to' leading to the root,
// exclude this edge
return false;
}
return true;
});
};
@@ -0,0 +1,36 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { EntityEdge } from '../types';
export interface TransformerContext {
/**
* The distance from an entity node to a root entity
*
* NOTE: This is not set until the setDistances transformation is applied
*/
nodeDistances: Map<string, number>;
edges: EntityEdge[];
// Options:
rootEntityRefs: string[];
unidirectional: boolean;
maxDepth: number;
forwardRelations: string[];
}
export type GraphTransformer = (options: TransformerContext) => void;
@@ -0,0 +1,151 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 } from '@backstage/catalog-model';
import type { EntityEdge, RelationPairs } from '../types';
export interface BuildGraphOptions {
rootEntityRefs: readonly string[];
entities: {
[ref: string]: Entity;
};
includeRelation: (type: string) => boolean;
kinds: readonly string[] | undefined;
mergeRelations: boolean;
relationPairs: RelationPairs;
unidirectional: boolean;
}
export function buildGraph({
rootEntityRefs,
entities,
includeRelation,
kinds,
mergeRelations,
relationPairs,
unidirectional,
}: BuildGraphOptions): EntityEdge[] {
const edges: EntityEdge[] = [];
const visitedNodes = new Set<string>();
const nodeQueue = [...rootEntityRefs];
const hasEdge = (
fromRef: string,
toRef: string,
relation: [string, string?],
): boolean => {
return edges.some(
edge =>
fromRef === edge.from &&
toRef === edge.to &&
edge.relations.some(
rel => rel[0] === relation[0] && rel[1] === relation[1],
),
);
};
while (nodeQueue.length > 0) {
const entityRef = nodeQueue.pop()!;
const entity = entities[entityRef];
visitedNodes.add(entityRef);
if (entity) {
entity?.relations?.forEach(rel => {
// Check if the related entity should be displayed, if not, ignore
// the relation too
if (!entities[rel.targetRef]) {
return;
}
if (!includeRelation(rel.type)) {
return;
}
if (
kinds &&
!kinds.some(kind =>
rel.targetRef.startsWith(`${kind.toLocaleLowerCase('en-US')}:`),
)
) {
return;
}
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
const [left] = pair;
const from = left === rel.type ? entityRef : rel.targetRef;
const to = left === rel.type ? rel.targetRef : entityRef;
if (!unidirectional || !hasEdge(from, to, pair)) {
edges.push({
from,
to,
relations: pair,
label: 'visible',
});
}
} else {
if (
!unidirectional ||
!hasEdge(entityRef, rel.targetRef, [rel.type])
) {
edges.push({
from: entityRef,
to: rel.targetRef,
relations: [rel.type],
label: 'visible',
});
}
}
if (!visitedNodes.has(rel.targetRef)) {
nodeQueue.push(rel.targetRef);
visitedNodes.add(rel.targetRef);
}
// if unidirectional add missing relations as entities are only visited once
if (unidirectional) {
const findIndex = edges.findIndex(
edge =>
entityRef === edge.from &&
rel.targetRef === edge.to &&
!edge.relations.includes(rel.type),
);
if (findIndex >= 0) {
if (mergeRelations) {
const pair = relationPairs.find(
([l, r]) => l === rel.type || r === rel.type,
) ?? [rel.type];
edges[findIndex].relations = [
...edges[findIndex].relations,
...pair,
];
} else {
edges[findIndex].relations = [
...edges[findIndex].relations,
rel.type,
];
}
}
}
});
}
}
return edges;
}
@@ -0,0 +1,18 @@
/*
* Copyright 2025 The Backstage Authors
*
* 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 { buildGraph } from './build-graph';
export type { BuildGraphOptions } from './build-graph';
@@ -14,5 +14,13 @@
* limitations under the License.
*/
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations';
export type { RelationPairs } from './relations';
export { ALL_RELATIONS, ALL_RELATION_PAIRS } from './relations';
export type {
EntityEdgeData,
EntityEdge,
EntityNodeData,
EntityNode,
} from './graph';
export { Direction } from './graph';