diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.test.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.test.tsx new file mode 100644 index 0000000000000..1cadedede0dd0 --- /dev/null +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.test.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type cytoscape from 'cytoscape'; +import { filterValidElements } from './cytoscape'; + +const node = (id: string): cytoscape.ElementDefinition => ({ + data: { id }, +}); + +const edge = (id: string, source: string, target: string): cytoscape.ElementDefinition => ({ + data: { id, source, target }, +}); + +describe('filterValidElements', () => { + it('returns all elements when every edge references existing nodes', () => { + const elements = [node('a'), node('b'), edge('a->b', 'a', 'b')]; + + expect(filterValidElements(elements)).toEqual(elements); + }); + + it('removes edges whose source node does not exist', () => { + const elements = [node('a'), edge('x->a', 'x', 'a')]; + + expect(filterValidElements(elements)).toEqual([node('a')]); + }); + + it('removes edges whose target node does not exist', () => { + const elements = [node('a'), edge('a->x', 'a', 'x')]; + + expect(filterValidElements(elements)).toEqual([node('a')]); + }); + + it('removes edges where both source and target nodes are missing', () => { + const elements = [node('a'), edge('x->y', 'x', 'y')]; + + expect(filterValidElements(elements)).toEqual([node('a')]); + }); + + it('keeps nodes that have no edges', () => { + const elements = [node('a'), node('b')]; + + expect(filterValidElements(elements)).toEqual(elements); + }); + + it('returns an empty array when given an empty array', () => { + expect(filterValidElements([])).toEqual([]); + }); + + it('handles a mix of valid and orphaned edges', () => { + const elements = [ + node('a'), + node('b'), + edge('a->b', 'a', 'b'), + edge('a->missing', 'a', 'missing'), + edge('missing->b', 'missing', 'b'), + ]; + + expect(filterValidElements(elements)).toEqual([node('a'), node('b'), edge('a->b', 'a', 'b')]); + }); + + it('keeps multiple valid edges between the same nodes', () => { + const elements = [node('a'), node('b'), edge('e1', 'a', 'b'), edge('e2', 'a', 'b')]; + + expect(filterValidElements(elements)).toEqual(elements); + }); + + it('handles elements with only edges and no nodes', () => { + const elements = [edge('a->b', 'a', 'b')]; + + expect(filterValidElements(elements)).toEqual([]); + }); +}); diff --git a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.tsx b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.tsx index e780f3d6bdb3d..66cb6b70921cb 100644 --- a/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.tsx +++ b/x-pack/solutions/observability/plugins/apm/public/components/app/service_map/cytoscape.tsx @@ -18,6 +18,26 @@ cytoscape.use(dagre); export const CytoscapeContext = createContext(undefined); +/** + * Filters out edges whose source or target node is not present in the + * elements list. This prevents Cytoscape from throwing when the graph + * data contains inconsistencies (e.g. an edge referencing a node that + * was removed by server-side grouping). + */ +export const filterValidElements = ( + elements: cytoscape.ElementDefinition[] +): cytoscape.ElementDefinition[] => { + const nodeIds = new Set( + elements.filter((el) => !el.data.source && !el.data.target).map((el) => el.data.id) + ); + return elements.filter((el) => { + if (el.data.source || el.data.target) { + return nodeIds.has(el.data.source) && nodeIds.has(el.data.target); + } + return true; + }); +}; + export interface CytoscapeProps { children?: ReactNode; elements: cytoscape.ElementDefinition[]; @@ -64,16 +84,18 @@ function CytoscapeComponent({ children, elements, height, serviceName, style }: // We do a fit if we're going from 0 to >0 elements const fit = cy.elements().length === 0; - cy.add(elements); + const validElements = filterValidElements(elements); + + cy.add(validElements); // Remove any old elements that don't exist in the new set of elements. - const elementIds = elements.map((element) => element.data.id); + const elementIds = validElements.map((element) => element.data.id); cy.elements().forEach((element) => { if (!elementIds.includes(element.data('id'))) { cy.remove(element); } else { // Doing an "add" with an element with the same id will keep the original // element. Set the data with the new element data. - const newElement = elements.find((el) => el.data.id === element.id()); + const newElement = validElements.find((el) => el.data.id === element.id()); element.data(newElement?.data ?? element.data()); } });