Skip to content

Commit

Permalink
Merge pull request #2538 from headlamp-k8s/map-perf
Browse files Browse the repository at this point in the history
frontend: Improve Map performance
  • Loading branch information
illume authored Nov 8, 2024
2 parents 27a9fc7 + 21abddc commit 5113bf1
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 96 deletions.
2 changes: 1 addition & 1 deletion frontend/src/components/resourceMap/GraphView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 0 additions & 26 deletions frontend/src/components/resourceMap/graph/graphFiltering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
56 changes: 21 additions & 35 deletions frontend/src/components/resourceMap/graph/graphFiltering.ts
Original file line number Diff line number Diff line change
@@ -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<string>;
}
| {
type: 'related';
id: string;
}
| {
type: 'custom';
label: string;
filterFn: (node: GraphNode) => boolean;
};

/**
Expand All @@ -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
Expand All @@ -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);
}
});
}
Expand All @@ -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';
}
Expand All @@ -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) {
Expand Down
74 changes: 44 additions & 30 deletions frontend/src/components/resourceMap/graph/graphGrouping.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { groupBy } from 'lodash';
import Pod from '../../../lib/k8s/pod';
import { makeGraphLookup } from './graphLookup';
import {
forEachNode,
GraphEdge,
Expand Down Expand Up @@ -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<string>();
const visitedEdges = new Set<string>();

/**
* Recursively finds all nodes in the connected component of a given node
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/components/resourceMap/graph/graphLookup.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
56 changes: 56 additions & 0 deletions frontend/src/components/resourceMap/graph/graphLookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { GraphEdge, GraphNode } from './graphModel';

/**
* Constant time lookup of graph elements
*/
export interface GraphLookup<N, E> {
/** 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<N extends GraphNode, E extends GraphEdge>(
nodes: N[],
edges: E[]
): GraphLookup<N, E> {
const nodeMap = new Map<string, N>();
nodes.forEach(n => {
nodeMap.set(n.id, n);
});

// Create map for incoming and outgoing edges where key is node ID
const outgoingEdges = new Map<string, E[]>();
const incomingEdges = new Map<string, E[]>();

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);
},
};
}
Loading

0 comments on commit 5113bf1

Please sign in to comment.