diff --git a/frontend/src/components/resourceMap/GraphView.tsx b/frontend/src/components/resourceMap/GraphView.tsx index 2ec348fa05..97dd08c15e 100644 --- a/frontend/src/components/resourceMap/GraphView.tsx +++ b/frontend/src/components/resourceMap/GraphView.tsx @@ -129,7 +129,7 @@ function GraphViewContent({ if (hasErrorsFilter) { filters.push({ type: 'hasErrors' }); } - if (namespaces) { + if (namespaces?.size > 0) { filters.push({ type: 'namespace', namespaces }); } return filterGraph(nodes, edges, filters); diff --git a/frontend/src/components/resourceMap/graph/graphFiltering.test.ts b/frontend/src/components/resourceMap/graph/graphFiltering.test.ts index c4f9015ead..b3943f2d7b 100644 --- a/frontend/src/components/resourceMap/graph/graphFiltering.test.ts +++ b/frontend/src/components/resourceMap/graph/graphFiltering.test.ts @@ -40,14 +40,6 @@ describe('filterGraph', () => { { id: 'e2', source: '3', target: '4', type: 'kubeRelation' }, ]; - it('filters nodes by name', () => { - const filters: GraphFilter[] = [{ type: 'name', query: 'node1' }]; - const { nodes: filteredNodes } = filterGraph(nodes, edges, filters); - - // Output contains node1 and node related to it node 2 - expect(filteredNodes.map(it => it.id)).toEqual(['1', '2']); - }); - it('filters nodes by namespace', () => { const filters: GraphFilter[] = [{ type: 'namespace', namespaces: new Set(['ns3']) }]; const { nodes: filteredNodes } = filterGraph(nodes, edges, filters); @@ -56,24 +48,6 @@ describe('filterGraph', () => { expect(filteredNodes.map(it => it.id)).toEqual(['3', '4']); }); - it('filters nodes by related nodes', () => { - const filters: GraphFilter[] = [{ type: 'related', id: '1' }]; - const { nodes: filteredNodes } = filterGraph(nodes, edges, filters); - - // Output contains node with id 1 and node 2 that is related to it - expect(filteredNodes.map(it => it.id)).toEqual(['1', '2']); - }); - - it('filters nodes by custom filter function', () => { - const filters: GraphFilter[] = [ - { type: 'custom', label: 'custom', filterFn: node => node.id === '1' }, - ]; - const { nodes: filteredNodes } = filterGraph(nodes, edges, filters); - - // Custom filter includes node with id 1 and node 2 that is related to it - expect(filteredNodes.map(it => it.id)).toEqual(['1', '2']); - }); - it('filters nodes by error status', () => { const filters: GraphFilter[] = [{ type: 'hasErrors' }]; const { nodes: filteredNodes } = filterGraph(nodes, edges, filters); diff --git a/frontend/src/components/resourceMap/graph/graphFiltering.ts b/frontend/src/components/resourceMap/graph/graphFiltering.ts index 3564617bbb..016e652496 100644 --- a/frontend/src/components/resourceMap/graph/graphFiltering.ts +++ b/frontend/src/components/resourceMap/graph/graphFiltering.ts @@ -1,26 +1,14 @@ import { getStatus } from '../nodes/KubeObjectStatus'; +import { makeGraphLookup } from './graphLookup'; import { GraphEdge, GraphNode } from './graphModel'; export type GraphFilter = - | { - type: 'name'; - query: string; - } | { type: 'hasErrors'; } | { type: 'namespace'; namespaces: Set; - } - | { - type: 'related'; - id: string; - } - | { - type: 'custom'; - label: string; - filterFn: (node: GraphNode) => boolean; }; /** @@ -31,23 +19,26 @@ export type GraphFilter = * even if they don't match the filter * * The filters can be of the following types: - * - `name`: Filters nodes by the name - * - `related`: Keeps only the node with the id and nodes connected to it with edges * - `hasErrors`: Filters nodes that have errors based on their resource status. See {@link getStatus} * - `namespace`: Filters nodes by their namespace - * - `custom`: Filters nodes using a custom filter function provided in the filter * * @param nodes - List of all the nodes in the graph * @param edges - List of all the edges in the graph * @param filters - List of fitlers to apply */ export function filterGraph(nodes: GraphNode[], edges: GraphEdge[], filters: GraphFilter[]) { + if (filters.length === 0) { + return { nodes, edges }; + } + const filteredNodes: GraphNode[] = []; const filteredEdges: GraphEdge[] = []; const visitedNodes = new Set(); const visitedEdges = new Set(); + const graphLookup = makeGraphLookup(nodes, edges); + /** * Add all the nodes that are related to the given node * Related means connected by an edge @@ -57,21 +48,26 @@ export function filterGraph(nodes: GraphNode[], edges: GraphEdge[], filters: Gra if (visitedNodes.has(node.id)) return; visitedNodes.add(node.id); filteredNodes.push(node); - edges.forEach(edge => { - if (edge.source === node.id) { - const targetNode = nodes.find(it => it.id === edge.target); - if (targetNode && !visitedNodes.has(targetNode.id)) pushRelatedNodes(targetNode); - } - if (edge.target === node.id) { - const sourceNode = nodes.find(it => it.id === edge.source); - if (sourceNode && !visitedNodes.has(sourceNode.id)) pushRelatedNodes(sourceNode); + + graphLookup.getOutgoingEdges(node.id)?.forEach(edge => { + const targetNode = graphLookup.getNode(edge.target); + if (targetNode && !visitedNodes.has(targetNode.id)) { + if (!visitedEdges.has(edge.id)) { + visitedEdges.add(edge.id); + filteredEdges.push(edge); + } + pushRelatedNodes(targetNode); } + }); - if (edge.target === node.id || edge.source === node.id) { + graphLookup.getIncomingEdges(node.id)?.forEach(edge => { + const sourceNode = graphLookup.getNode(edge.source); + if (sourceNode && !visitedNodes.has(sourceNode.id)) { if (!visitedEdges.has(edge.id)) { visitedEdges.add(edge.id); filteredEdges.push(edge); } + pushRelatedNodes(sourceNode); } }); } @@ -80,13 +76,6 @@ export function filterGraph(nodes: GraphNode[], edges: GraphEdge[], filters: Gra let keep = true; filters.forEach(filter => { - if (filter.type === 'name' && filter.query.trim().length > 0) { - keep &&= - 'resource' in node.data && node.data.resource?.metadata?.name?.includes(filter.query); - } - if (filter.type === 'related') { - keep &&= node.id === filter.id; - } if (filter.type === 'hasErrors') { keep &&= 'resource' in node.data && getStatus(node?.data?.resource) !== 'success'; } @@ -96,9 +85,6 @@ export function filterGraph(nodes: GraphNode[], edges: GraphEdge[], filters: Gra !!node.data?.resource?.metadata?.namespace && filter.namespaces.has(node.data?.resource?.metadata?.namespace); } - if (filter.type === 'custom') { - keep &&= filter.filterFn(node); - } }); if (keep) { diff --git a/frontend/src/components/resourceMap/graph/graphGrouping.tsx b/frontend/src/components/resourceMap/graph/graphGrouping.tsx index 28d76c3611..451927c97d 100644 --- a/frontend/src/components/resourceMap/graph/graphGrouping.tsx +++ b/frontend/src/components/resourceMap/graph/graphGrouping.tsx @@ -1,5 +1,6 @@ import { groupBy } from 'lodash'; import Pod from '../../../lib/k8s/pod'; +import { makeGraphLookup } from './graphLookup'; import { forEachNode, GraphEdge, @@ -37,7 +38,11 @@ export const getGraphSize = (graph: GraphNode) => { */ const getConnectedComponents = (nodes: KubeObjectNode[], edges: GraphEdge[]): GraphNode[] => { const components: KubeGroupNode[] = []; - const visited: { [key: string]: boolean } = {}; + + const graphLookup = makeGraphLookup(nodes, edges); + + const visitedNodes = new Set(); + const visitedEdges = new Set(); /** * Recursively finds all nodes in the connected component of a given node @@ -47,31 +52,51 @@ const getConnectedComponents = (nodes: KubeObjectNode[], edges: GraphEdge[]): Gr * @param node - The starting node for the connected component search * @param componentNodes - An array to store the nodes that are part of the connected component */ - const findConnectedComponent = (node: KubeObjectNode, componentNodes: KubeObjectNode[]) => { - visited[node.id] = true; + const findConnectedComponent = ( + node: KubeObjectNode, + componentNodes: KubeObjectNode[], + componentEdges: GraphEdge[] + ) => { + visitedNodes.add(node.id); componentNodes.push(node); - edges.forEach(edge => { - if (edge.source === node.id && !visited[edge.target]) { - const targetNode = nodes.find(n => n.id === edge.target); - if (targetNode) findConnectedComponent(targetNode, componentNodes); - } else if (edge.target === node.id && !visited[edge.source]) { - const sourceNode = nodes.find(n => n.id === edge.source); - if (sourceNode) findConnectedComponent(sourceNode, componentNodes); + + graphLookup.getOutgoingEdges(node.id)?.forEach(edge => { + if (visitedNodes.has(edge.target)) return; + + if (!visitedEdges.has(edge.id)) { + visitedEdges.add(edge.id); + componentEdges.push(edge); + } + + const targetNode = graphLookup.getNode(edge.target); + if (targetNode) { + componentEdges.push(edge); + findConnectedComponent(targetNode, componentNodes, componentEdges); + } + }); + + graphLookup.getIncomingEdges(node.id)?.forEach(edge => { + if (visitedNodes.has(edge.source)) return; + + if (!visitedEdges.has(edge.id)) { + visitedEdges.add(edge.id); + componentEdges.push(edge); + } + + const sourceNode = graphLookup.getNode(edge.source); + if (sourceNode) { + componentEdges.push(edge); + findConnectedComponent(sourceNode, componentNodes, componentEdges); } }); }; // Iterate over each node and find connected components nodes.forEach(node => { - if (!visited[node.id]) { + if (!visitedNodes.has(node.id)) { const componentNodes: KubeObjectNode[] = []; - findConnectedComponent(node, componentNodes); - // Find edges for the current component - const componentEdges = edges.filter( - edge => - componentNodes.find(n => n.id === edge.source) && - componentNodes.find(n => n.id === edge.target) - ); + const componentEdges: GraphEdge[] = []; + findConnectedComponent(node, componentNodes, componentEdges); const mainNode = getMainNode(componentNodes); const id = 'group-' + mainNode.id; @@ -175,18 +200,7 @@ export function groupGraph( }, }; - let components: GraphNode[] = []; - const groupComponents = true; - if (groupComponents) { - const relationEdges = edges.filter(it => it.type === 'kubeRelation'); - const elseEdges = edges.filter(it => it.type !== 'kubeRelation'); - root.data.edges.push(...elseEdges); - const groups = getConnectedComponents(nodes, relationEdges); - components = groups; - } else { - root.data.nodes = nodes; - root.data.edges = edges; - } + let components: GraphNode[] = getConnectedComponents(nodes, edges); if (groupBy === 'namespace') { // Create groups based on the Kube resource namespace diff --git a/frontend/src/components/resourceMap/graph/graphLookup.test.ts b/frontend/src/components/resourceMap/graph/graphLookup.test.ts new file mode 100644 index 0000000000..c7cb8ced54 --- /dev/null +++ b/frontend/src/components/resourceMap/graph/graphLookup.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { makeGraphLookup } from './graphLookup'; +import { GraphEdge, GraphNode } from './graphModel'; + +describe('GraphLookup', () => { + const nodes: GraphNode[] = [ + { id: '1', type: 'kubeObject', data: {} as any }, + { id: '2', type: 'kubeObject', data: {} as any }, + { id: '3', type: 'kubeObject', data: {} as any }, + ]; + + const edges: GraphEdge[] = [ + { id: 'e1', source: '1', target: '2', type: 'typeA' }, + { id: 'e2', source: '2', target: '3', type: 'typeB' }, + { id: 'e3', source: '1', target: '3', type: 'typeC' }, + ]; + + const graphLookup = makeGraphLookup(nodes, edges); + + it('should get outgoing edges for a node', () => { + const outgoingEdges = graphLookup.getOutgoingEdges('1'); + expect(outgoingEdges).toEqual([ + { id: 'e1', source: '1', target: '2', type: 'typeA' }, + { id: 'e3', source: '1', target: '3', type: 'typeC' }, + ]); + }); + + it('should get incoming edges for a node', () => { + const incomingEdges = graphLookup.getIncomingEdges('3'); + expect(incomingEdges).toEqual([ + { id: 'e2', source: '2', target: '3', type: 'typeB' }, + { id: 'e3', source: '1', target: '3', type: 'typeC' }, + ]); + }); + + it('should get a node by its ID', () => { + const node = graphLookup.getNode('2'); + expect(node).toEqual({ id: '2', type: 'kubeObject', data: {} }); + }); + + it('should return undefined for non-existent node ID', () => { + const node = graphLookup.getNode('non-existent'); + expect(node).toBeUndefined(); + }); + + it('should return undefined for outgoing edges of non-existent node ID', () => { + const outgoingEdges = graphLookup.getOutgoingEdges('non-existent'); + expect(outgoingEdges).toBeUndefined(); + }); + + it('should return undefined for incoming edges of non-existent node ID', () => { + const incomingEdges = graphLookup.getIncomingEdges('non-existent'); + expect(incomingEdges).toBeUndefined(); + }); +}); diff --git a/frontend/src/components/resourceMap/graph/graphLookup.ts b/frontend/src/components/resourceMap/graph/graphLookup.ts new file mode 100644 index 0000000000..bd5bd9399e --- /dev/null +++ b/frontend/src/components/resourceMap/graph/graphLookup.ts @@ -0,0 +1,56 @@ +import { GraphEdge, GraphNode } from './graphModel'; + +/** + * Constant time lookup of graph elements + */ +export interface GraphLookup { + /** Get list of outgoing edges from the given node */ + getOutgoingEdges(nodeId: string): E[] | undefined; + /** Get list of incoming edges to the given node */ + getIncomingEdges(nodeId: string): E[] | undefined; + /** Get Node by its' ID */ + getNode(nodeId: string): N | undefined; +} + +/** + * Creates a utility for constant time lookup of graph elements + * + * @param nodes - list of graph Nodes + * @param edges - list of graph Edges + * @returns lookup {@link GraphLookup} + */ +export function makeGraphLookup( + nodes: N[], + edges: E[] +): GraphLookup { + const nodeMap = new Map(); + nodes.forEach(n => { + nodeMap.set(n.id, n); + }); + + // Create map for incoming and outgoing edges where key is node ID + const outgoingEdges = new Map(); + const incomingEdges = new Map(); + + edges.forEach(edge => { + const s = outgoingEdges.get(edge.source) ?? []; + s.push(edge); + outgoingEdges.set(edge.source, s); + + const t = incomingEdges.get(edge.target) ?? []; + t.push(edge); + incomingEdges.set(edge.target, t); + }); + + return { + getOutgoingEdges(nodeId) { + return outgoingEdges.get(nodeId); + }, + getIncomingEdges(nodeId) { + return incomingEdges.get(nodeId); + }, + getNode(nodeId) { + return nodeMap.get(nodeId); + }, + }; +} diff --git a/frontend/src/components/resourceMap/sources/GraphSources.tsx b/frontend/src/components/resourceMap/sources/GraphSources.tsx index ee872c2e5d..7b69382c71 100644 --- a/frontend/src/components/resourceMap/sources/GraphSources.tsx +++ b/frontend/src/components/resourceMap/sources/GraphSources.tsx @@ -232,13 +232,17 @@ export function GraphSourceManager({ sources, children }: GraphSourceManagerProp const contextValue = useThrottledMemo( () => { - const nodes: GraphNode[] = []; - const edges: GraphEdge[] = []; + let nodes: GraphNode[] = []; + let edges: GraphEdge[] = []; selectedSources.forEach(id => { const data = sourceData.get(id); - nodes.push(...(data?.nodes ?? [])); - edges.push(...(data?.edges ?? [])); + if (data?.nodes) { + nodes = nodes.concat(data.nodes); + } + if (data?.edges) { + edges = edges.concat(data.edges); + } }); const isLoading =