UNPKG

@kinvolk/headlamp-plugin

Version:

The needed infrastructure for building Headlamp plugins.

311 lines (310 loc) 12 kB
/* * 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; }