Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

frontend: Improve Map performance #2538

Merged
merged 6 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading