@kinvolk/headlamp-plugin
Version:
The needed infrastructure for building Headlamp plugins.
311 lines (310 loc) • 12 kB
JavaScript
/*
* Copyright 2025 The Kubernetes 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 { groupBy } from 'lodash';
import { makeGraphLookup } from './graphLookup';
import { forEachNode } from './graphModel';
/**
* Returns the amount of nodes in the graph
*/
export const getGraphSize = (graph) => {
let size = 0;
forEachNode(graph, () => {
size++;
});
return size;
};
/**
* Identifies and groups connected components from a set of nodes and edges.
* Connected component is a subgraph where all nodes are connected to each other
* but not to any other node in the graph. Essentialy a separate subgraph
*
* @param nodes - An array of `KubeObjectNode` representing the nodes in the graph
* @param edges - An array of `GraphEdge` representing the edges in the graph
* @returns An array of `GraphNode` where each element is either a single node
* or a group node containing multiple nodes and edges
*/
const getConnectedComponents = (nodes, edges) => {
const components = [];
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
* This function performs a depth-first search (DFS) to traverse and collect all nodes
* that are part of the same connected component as the provided node
*
* @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, componentNodes, componentEdges) => {
visitedNodes.add(node.id);
componentNodes.push(node);
// Outgoing edges
graphLookup.getOutgoingEdges(node.id)?.forEach(edge => {
// Always collect the edge if we haven't yet
if (!visitedEdges.has(edge.id)) {
visitedEdges.add(edge.id);
componentEdges.push(edge);
}
// Only recurse further if we haven't visited the target node
if (!visitedNodes.has(edge.target)) {
const targetNode = graphLookup.getNode(edge.target);
if (targetNode) {
findConnectedComponent(targetNode, componentNodes, componentEdges);
}
}
});
// Incoming edges
graphLookup.getIncomingEdges(node.id)?.forEach(edge => {
// Always collect the edge if we haven't yet
if (!visitedEdges.has(edge.id)) {
visitedEdges.add(edge.id);
componentEdges.push(edge);
}
// Only recurse further if we haven't visited the source node
if (!visitedNodes.has(edge.source)) {
const sourceNode = graphLookup.getNode(edge.source);
if (sourceNode) {
findConnectedComponent(sourceNode, componentNodes, componentEdges);
}
}
});
};
// Iterate over each node and find connected components
nodes.forEach(node => {
if (!visitedNodes.has(node.id)) {
const componentNodes = [];
const componentEdges = [];
findConnectedComponent(node, componentNodes, componentEdges);
const mainNode = getMainNode(componentNodes);
const id = 'group-' + mainNode.id;
components.push({
id: id,
nodes: componentNodes,
edges: componentEdges,
});
}
});
return components.map(it => (it.nodes?.length === 1 ? it.nodes[0] : it));
};
/**
* Try to find a "main" node in the workload group
* If can't find anything return the first node
*/
export const getMainNode = (nodes) => {
const deployment = nodes.find(it => it.kubeObject?.kind === 'Deployment');
const replicaSet = nodes.find(it => it.kubeObject?.kind === 'ReplicaSet');
const daemonSet = nodes.find(it => it.kubeObject?.kind === 'DaemonSet');
const statefulSet = nodes.find(it => it.kubeObject?.kind === 'StatefulSet');
const job = nodes.find(it => it.kubeObject?.kind === 'Job');
const cronJob = nodes.find(it => it.kubeObject?.kind === 'CronJob');
return deployment ?? replicaSet ?? daemonSet ?? statefulSet ?? cronJob ?? job ?? nodes[0];
};
/**
* Groups a list of nodes into 'group' type nodes
* Groping property is determined by the accessor
*
* @param nodes - list of nodes
* @param accessor - function returning which property to group by
* @param param.label - label prefix for the group
* @param param.allowSingleMemberGroup - won't create groups with single members if set to false
* @returns List of created groups
*/
const groupByProperty = (nodes, accessor, { label, allowSingleMemberGroup = false, }) => {
const groups = Object.entries(groupBy(nodes, node => {
return accessor(node);
})).map(([property, components]) => ({
id: label + '-' + property,
nodes: components,
edges: [],
subtitle: label,
label: property,
}));
const result = groups
.flatMap(it => {
const nonGroup = it.id.includes('undefined');
const hasOneMember = it.nodes?.length === 1;
return nonGroup || (hasOneMember && !allowSingleMemberGroup) ? it.nodes : [it];
})
.filter(Boolean);
return result;
};
/**
* Groups the graph into separate 'group' Nodes
* Nodes within groups are sorted by size
*
* @param nodes - List of nodes
* @param edges - List of edge
* @param params.groupBy - group by which property
* @returns Graph, a single root node with groups as its' children
*/
export function groupGraph(nodes, edges, { groupBy, namespaces, k8sNodes, }) {
const root = {
id: 'root',
label: 'root',
nodes: [],
edges: [],
};
let components = getConnectedComponents(nodes, edges);
if (groupBy === 'namespace') {
// Create groups based on the Kube resource namespace
components = groupByProperty(components, component => {
if (component.nodes) {
return component.nodes.find(node => node.kubeObject)?.kubeObject?.metadata?.namespace;
}
return component.kubeObject?.metadata?.namespace;
}, { label: 'Namespace', allowSingleMemberGroup: true });
components.forEach(component => {
if (!component.kubeObject) {
component.kubeObject = namespaces.find(namespace => namespace.metadata.name === component.label);
if (component.kubeObject) {
component.id = component.kubeObject.metadata.uid;
}
}
});
}
if (groupBy === 'node') {
// Create groups based on the Kube resource node
components = groupByProperty(components, component => {
if (component.nodes) {
return component.nodes.find(node => node.kubeObject?.kind === 'Pod')?.kubeObject
?.spec?.nodeName;
}
return component.kubeObject?.spec?.nodeName;
}, { label: 'Node', allowSingleMemberGroup: true });
components.forEach(component => {
if (!component.kubeObject) {
component.kubeObject = k8sNodes.find(namespace => namespace.metadata.name === component.label);
if (component.kubeObject) {
component.id = component.kubeObject.metadata.uid;
}
}
});
}
if (groupBy === 'instance') {
// Create groups based on the instance label from metadata (if it exists)
components = groupByProperty(components, node => {
if (node.nodes) {
const mainNode = getMainNode(node.nodes.filter(node => !node.nodes));
return mainNode.kubeObject?.metadata?.labels?.['app.kubernetes.io/instance'];
}
return node.kubeObject?.metadata?.labels?.['app.kubernetes.io/instance'];
}, { label: 'Instance' });
}
root.nodes?.push(...components);
// Sort nodes within each group node
forEachNode(root, node => {
/**
* Sort elements, giving priority to bigger groups
*/
const getNodeWeight = (n) => {
if (n.edges && n.nodes) {
const someEdges = n.edges.length > 0;
return 1 + (someEdges ? 100 : 0) + n.nodes.length;
}
return 1;
};
if (node.nodes) {
node.nodes.sort((a, b) => getNodeWeight(b) - getNodeWeight(a));
}
});
return root;
}
/**
* Walks the graph do find the parent of the given node
*/
export function getParentNode(graph, elementId) {
let result;
forEachNode(graph, node => {
if (node.nodes?.find(it => it.id === elementId)) {
result = node;
}
});
return result;
}
/**
* Finds a Node with a group type that contains a given node
* @param graph - graph which contains the Node
* @param elementId - ID of a given Node
* @param strict - If set to false will try to find closest group, if set to true always returns the parent
* @returns
*/
export function findGroupContaining(graph, elementId, strict) {
// Group is actually selcted, not a node inside a group
if (graph.id === elementId && !strict)
return graph;
// Node is inside this group
if (graph.nodes?.find(it => (strict ? it.id === elementId : it.id === elementId && !it.nodes))) {
return graph;
}
if (graph.nodes) {
let res;
graph.nodes?.some(node => {
const group = findGroupContaining(node, elementId);
if (group) {
res = group;
return true;
}
return false;
});
if (res) {
return res;
}
}
return undefined;
}
/**
* Given a graph with groups, this function will 'collapse' all groups without
* the selected node. 'Collapsing' means that group won't show all children but
* only a preview
*
* If selectedNodeId is passed, only shows group containing that node
*
* @param graph Single graph node
* @param params.selectedNodeId Graph node that is selected
* @param params.expandAll Display all the children within all groups
* @returns Collapsed graph
*/
export function collapseGraph(graph, { selectedNodeId = 'root', expandAll }) {
let root = { ...graph };
let selectedGroup;
if (selectedNodeId) {
selectedGroup = findGroupContaining(graph, selectedNodeId);
}
/**
* Recursively collapse graph starting from a given Node
* Hides children if necessary
* @param group - given Node
* @returns Collapsed node
*/
const collapseGroup = (group) => {
const isBig = (group.nodes?.length ?? 0) > 10 || (group.edges?.length ?? 0) > 0;
const isSelectedGroup = selectedGroup?.id === group.id;
const isRoot = group.id === 'root';
const collapsed = !expandAll && !isRoot && !isSelectedGroup && isBig;
return {
...group,
nodes: group.nodes?.map(collapseGroup),
edges: group.edges,
collapsed,
};
};
if (selectedGroup && selectedGroup.id !== 'root') {
root.nodes = [selectedGroup];
}
root = collapseGroup(root);
return root;
}