/usr/share/grafana/public/app/plugins/panel/nodeGraph
import { DataFrame, Field, FieldCache, FieldColorModeId, FieldConfig, FieldType, MutableDataFrame, NodeGraphDataFrameFieldNames, } from '@grafana/data'; import { nodeR } from './Node'; import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types'; type Line = { x1: number; y1: number; x2: number; y2: number }; /** * Makes line shorter while keeping its middle in the same place. * This is manly used to add some empty space between an edge line and its source and target nodes, to make it nicer. * * @param line a line, where x1 and y1 are the coordinates of the source node center, and x2 and y2 are the coordinates of the target node center * @param sourceNodeRadius radius of the source node (possibly taking into account the thickness of the node circumference line, etc.) * @param targetNodeRadius radius of the target node (possibly taking into account the thickness of the node circumference line, etc.) * @param arrowHeadHeight height of the arrow head (in pixels) */ export function shortenLine(line: Line, sourceNodeRadius: number, targetNodeRadius: number, arrowHeadHeight = 1): Line { const vx = line.x2 - line.x1; const vy = line.y2 - line.y1; const mag = Math.sqrt(vx * vx + vy * vy); const cosine = (line.x2 - line.x1) / mag; const sine = (line.y2 - line.y1) / mag; const scaledThickness = arrowHeadHeight - arrowHeadHeight / 10; // Reduce the line length (along its main direction) by: // - the radius of the source node // - the radius of the target node, // - a constant value, just to add some empty space // - the height of the arrow head; the bigger the arrow head, the better is to add even more empty space return { x1: line.x1 + cosine * (sourceNodeRadius + 5), y1: line.y1 + sine * (sourceNodeRadius + 5), x2: line.x2 - cosine * (targetNodeRadius + 3 + scaledThickness), y2: line.y2 - sine * (targetNodeRadius + 3 + scaledThickness), }; } export type NodeFields = { fixedX?: Field; fixedY?: Field; id?: Field; title?: Field; subTitle?: Field; mainStat?: Field; secondaryStat?: Field; arc: Field[]; details: Field[]; color?: Field; icon?: Field; nodeRadius?: Field; highlighted?: Field; isInstrumented?: Field; }; export function getNodeFields(nodes: DataFrame): NodeFields { const normalizedFrames = { ...nodes, fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })), }; const fieldsCache = new FieldCache(normalizedFrames); return { id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()), title: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.title.toLowerCase()), subTitle: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.subTitle.toLowerCase()), mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()), secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()), arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc), details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail), color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color), icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon), nodeRadius: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.nodeRadius.toLowerCase()), highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()), fixedX: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.fixedX.toLowerCase()), fixedY: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.fixedY.toLowerCase()), isInstrumented: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.isInstrumented.toLowerCase()), }; } export type EdgeFields = { id?: Field; source?: Field; target?: Field; mainStat?: Field; secondaryStat?: Field; details: Field[]; /** * @deprecated use `color` instead */ highlighted?: Field; thickness?: Field; color?: Field; strokeDasharray?: Field; }; export function getEdgeFields(edges: DataFrame): EdgeFields { const normalizedFrames = { ...edges, fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })), }; const fieldsCache = new FieldCache(normalizedFrames); return { id: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id.toLowerCase()), source: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase()), target: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.target.toLowerCase()), mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()), secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()), details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()), // @deprecated -- for edges use color instead highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()), thickness: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.thickness.toLowerCase()), color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color.toLowerCase()), strokeDasharray: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.strokeDasharray.toLowerCase()), }; } function findFieldsByPrefix(frame: DataFrame, prefix: string): Field[] { return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix))); } /** * Transform nodes and edges dataframes into array of objects that the layout code can then work with. */ export function processNodes( nodes: DataFrame | undefined, edges: DataFrame | undefined ): { nodes: NodeDatum[]; edges: EdgeDatum[]; hasFixedPositions?: boolean; legend?: Array<{ color: string; name: string; }>; } { if (!(edges || nodes)) { return { nodes: [], edges: [] }; } if (nodes) { const nodeFields = getNodeFields(nodes); if (!nodeFields.id) { throw new Error('id field is required for nodes data frame.'); } const hasFixedPositions = nodeFields.fixedX && nodeFields.fixedX.values.every((v) => Number.isFinite(v)) && nodeFields.fixedY && nodeFields.fixedY.values.every((v) => Number.isFinite(v)); // Throw an error if somebody is using fixedX and fixedY fields incorrectly. Other option is to ignore this but we // are not able to easily combine fixed and non-fixed position in layout so that behaviour would be undefined // and silent. if (!hasFixedPositions) { const somePosFilled = (nodeFields.fixedX && nodeFields.fixedX.values.some((v) => Number.isFinite(v))) || (nodeFields.fixedY && nodeFields.fixedY.values.some((v) => Number.isFinite(v))); if (somePosFilled) { throw new Error('If fixedX and fixedY fields are present, the values have to be all filled and valid'); } } // Create the nodes here const nodesMap: { [id: string]: NodeDatum } = {}; for (let i = 0; i < nodeFields.id.values.length; i++) { const id = nodeFields.id.values[i]; nodesMap[id] = makeNodeDatum(id, nodeFields, i); } // We may not have edges in case of single node let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges), nodesMap) : []; for (const e of edgeDatums) { // We are adding incoming edges count, so we can later on find out which nodes are the roots nodesMap[e.target].incoming++; } return { nodes: Object.values(nodesMap), edges: edgeDatums, hasFixedPositions, legend: nodeFields.arc.map((f) => { return { color: f.config.color?.fixedColor ?? '', name: f.config.displayName || f.name, }; }), }; } else { // We have only edges here, so we have to construct also nodes out of them // We checked that either node || edges has to be defined and if nodes aren't edges has to be defined edges = edges!; const nodesMap: { [id: string]: NodeDatumFromEdge } = {}; const edgeFields = getEdgeFields(edges); // Turn edges into reasonable filled in nodes for (let i = 0; i < edges.length; i++) { const { source, target } = makeNodeDatumsFromEdge(edgeFields, i); nodesMap[target.id] = nodesMap[target.id] || target; nodesMap[source.id] = nodesMap[source.id] || source; // Check the stats fields. They can be also strings which we cannot really aggregate so only aggregate in case // they are numbers. Here we just sum all incoming edges to get the final value for node. if (computableField(edgeFields.mainStat)) { nodesMap[target.id].mainStatNumeric = (nodesMap[target.id].mainStatNumeric ?? 0) + edgeFields.mainStat!.values[i]; } if (computableField(edgeFields.secondaryStat)) { nodesMap[target.id].secondaryStatNumeric = (nodesMap[target.id].secondaryStatNumeric ?? 0) + edgeFields.secondaryStat!.values[i]; } // We are adding incoming edges count, so we can later on find out which nodes are the roots nodesMap[target.id].incoming++; } let edgeDatums = processEdges(edges, edgeFields, nodesMap); // It is expected for stats to be Field, so we have to create them. const nodes = normalizeStatsForNodes(nodesMap, edgeFields); return { nodes, edges: edgeDatums, // Edge-only datasets never have fixedX/fixedY hasFixedPositions: false, }; } } /** * Turn data frame data into EdgeDatum that node graph understands * @param edges * @param edgeFields */ function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id: string]: NodeDatum }): EdgeDatum[] { if (!edgeFields.id) { throw new Error('id field is required for edges data frame.'); } return edgeFields.id.values.map((id, index) => { const target = edgeFields.target?.values[index]; const source = edgeFields.source?.values[index]; const sourceNode = nodesMap[source]; const targetNode = nodesMap[target]; return { id, dataFrameRowIndex: index, source, target, sourceNodeRadius: !sourceNode.nodeRadius ? nodeR : sourceNode.nodeRadius.values[sourceNode.dataFrameRowIndex], targetNodeRadius: !targetNode.nodeRadius ? nodeR : targetNode.nodeRadius.values[targetNode.dataFrameRowIndex], mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat.config, edgeFields.mainStat.values[index]) : '', secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index]) : '', // @deprecated -- for edges use color instead highlighted: edgeFields.highlighted?.values[index] || false, thickness: edgeFields.thickness?.values[index] || 1, color: edgeFields.color?.values[index], strokeDasharray: edgeFields.strokeDasharray?.values[index], }; }); } function computableField(field?: Field) { return field && field.type === FieldType.number; } /** * Instead of just simple numbers node graph requires to have Field in NodeDatum (probably for some formatting info in * config). So we create them here and fill with correct data. * @param nodesMap * @param edgeFields */ function normalizeStatsForNodes(nodesMap: { [id: string]: NodeDatumFromEdge }, edgeFields: EdgeFields): NodeDatum[] { const secondaryStatValues: Array<number | undefined> = []; const mainStatValues: Array<number | undefined> = []; const secondaryStatField = computableField(edgeFields.secondaryStat) ? { ...edgeFields.secondaryStat!, values: secondaryStatValues, } : undefined; const mainStatField = computableField(edgeFields.mainStat) ? { ...edgeFields.mainStat!, values: mainStatValues, } : undefined; return Object.values(nodesMap).map((node, index) => { if (mainStatField || secondaryStatField) { const newNode = { ...node, }; if (mainStatField) { newNode.mainStat = mainStatField; mainStatValues.push(node.mainStatNumeric); newNode.dataFrameRowIndex = index; } if (secondaryStatField) { newNode.secondaryStat = secondaryStatField; secondaryStatValues.push(node.secondaryStatNumeric); newNode.dataFrameRowIndex = index; } return newNode; } return node; }); } function makeNodeDatumsFromEdge(edgeFields: EdgeFields, index: number) { const targetId = edgeFields.target?.values[index]; const sourceId = edgeFields.source?.values[index]; return { target: makeSimpleNodeDatum(targetId, index), source: makeSimpleNodeDatum(sourceId, index), }; } function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge { return { id: name, title: name, subTitle: '', dataFrameRowIndex: index, incoming: 0, arcSections: [], highlighted: false, }; } function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeDatum { return { id: id, title: nodeFields.title?.values[index] || '', subTitle: nodeFields.subTitle?.values[index] || '', dataFrameRowIndex: index, incoming: 0, mainStat: nodeFields.mainStat, secondaryStat: nodeFields.secondaryStat, arcSections: nodeFields.arc, color: nodeFields.color, icon: nodeFields.icon?.values[index] || '', nodeRadius: nodeFields.nodeRadius, highlighted: nodeFields.highlighted?.values[index] || false, x: nodeFields.fixedX?.values[index] ?? undefined, y: nodeFields.fixedY?.values[index] ?? undefined, isInstrumented: nodeFields.isInstrumented?.values[index] ?? true, }; } export function statToString(config: FieldConfig, value: number | string): string { if (typeof value === 'string') { return value; } else { const decimals = config.decimals || 2; if (Number.isFinite(value)) { return value.toFixed(decimals) + (config.unit ? ' ' + config.unit : ''); } else { return ''; } } } /** * Utilities mainly for testing */ export function makeNodesDataFrame( count: number, partialNodes: Array<Partial<Record<NodeGraphDataFrameFieldNames, unknown>>> = [] ) { const frame = nodesFrame(); for (let i = 0; i < count; i++) { frame.add(makeNode(i, partialNodes[i])); } return frame; } function makeNode(index: number, partialNode: Partial<Record<NodeGraphDataFrameFieldNames, unknown>> = {}) { return { id: index.toString(), title: `service:${index}`, subtitle: 'service', arc__success: 0.5, arc__errors: 0.5, mainstat: 0.1, secondarystat: 2, color: 0.5, icon: 'database', noderadius: 40, isinstrumented: true, ...partialNode, }; } function nodesFrame() { const fields = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.title]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.subTitle]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.mainStat]: { values: [], type: FieldType.number, }, [NodeGraphDataFrameFieldNames.secondaryStat]: { values: [], type: FieldType.number, }, [NodeGraphDataFrameFieldNames.arc + 'success']: { values: [], type: FieldType.number, config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'green' } }, }, [NodeGraphDataFrameFieldNames.arc + 'errors']: { values: [], type: FieldType.number, config: { color: { mode: FieldColorModeId.Fixed, fixedColor: 'red' } }, }, [NodeGraphDataFrameFieldNames.color]: { values: [], type: FieldType.number, config: { color: { mode: FieldColorModeId.ContinuousGrYlRd } }, }, [NodeGraphDataFrameFieldNames.icon]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.nodeRadius]: { values: [], type: FieldType.number, }, [NodeGraphDataFrameFieldNames.isInstrumented]: { values: [], type: FieldType.boolean, }, }; return new MutableDataFrame({ name: 'nodes', fields: Object.entries(fields).map(([key, value]) => ({ ...value, name: key, })), }); } export function makeEdgesDataFrame( edges: Array<Partial<{ source: string; target: string; mainstat: number; secondarystat: number }>> ) { const frame = edgesFrame(); for (const edge of edges) { frame.add({ id: edge.source + '--' + edge.target, ...edge, }); } return frame; } function edgesFrame() { const fields = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.source]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.target]: { values: [], type: FieldType.string, }, [NodeGraphDataFrameFieldNames.mainStat]: { values: [], type: FieldType.number, }, [NodeGraphDataFrameFieldNames.secondaryStat]: { values: [], type: FieldType.number, }, }; return new MutableDataFrame({ name: 'edges', fields: Object.entries(fields).map(([key, value]) => ({ ...value, name: key, })), }); } export interface Bounds { top: number; right: number; bottom: number; left: number; center: { x: number; y: number; }; } /** * Get bounds of the graph meaning the extent of the nodes in all directions. */ export function graphBounds(nodes: NodeDatum[]): Bounds { if (nodes.length === 0) { return { top: 0, right: 0, bottom: 0, left: 0, center: { x: 0, y: 0 } }; } const bounds = nodes.reduce( (acc, node) => { if (node.x! > acc.right) { acc.right = node.x!; } if (node.x! < acc.left) { acc.left = node.x!; } if (node.y! > acc.bottom) { acc.bottom = node.y!; } if (node.y! < acc.top) { acc.top = node.y!; } return acc; }, { top: Infinity, right: -Infinity, bottom: -Infinity, left: Infinity } ); const y = bounds.top + (bounds.bottom - bounds.top) / 2; const x = bounds.left + (bounds.right - bounds.left) / 2; return { ...bounds, center: { x, y, }, }; } export function getNodeGraphDataFrames(frames: DataFrame[], options?: NodeGraphOptions) { // TODO: this not in sync with how other types of responses are handled. Other types have a query response // processing pipeline which ends up populating redux state with proper data. As we move towards more dataFrame // oriented API it seems like a better direction to move such processing into to visualisations and do minimal // and lazy processing here. Needs bigger refactor so keeping nodeGraph and Traces as they are for now. let nodeGraphFrames = frames.filter((frame) => { if (frame.meta?.preferredVisualisationType === 'nodeGraph') { return true; } if (frame.name === 'nodes' || frame.name === 'edges' || frame.refId === 'nodes' || frame.refId === 'edges') { return true; } const fieldsCache = new FieldCache(frame); if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.id)) { return true; } return false; }); // If panel options are provided, interpolate their values in to the data frames if (options) { nodeGraphFrames = applyOptionsToFrames(nodeGraphFrames, options); } return nodeGraphFrames; } export const applyOptionsToFrames = (frames: DataFrame[], options: NodeGraphOptions): DataFrame[] => { return frames.map((frame) => { const fieldsCache = new FieldCache(frame); // Edges frame has source which can be used to identify nodes vs edges frames if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase())) { if (options?.edges?.mainStatUnit) { const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat); if (field) { field.config = { ...field.config, unit: options.edges.mainStatUnit }; } } if (options?.edges?.secondaryStatUnit) { const field = frame.fields.find( (field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat ); if (field) { field.config = { ...field.config, unit: options.edges.secondaryStatUnit }; } } } else { if (options?.nodes?.mainStatUnit) { const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat); if (field) { field.config = { ...field.config, unit: options.nodes.mainStatUnit }; } } if (options?.nodes?.secondaryStatUnit) { const field = frame.fields.find( (field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat ); if (field) { field.config = { ...field.config, unit: options.nodes.secondaryStatUnit }; } } if (options?.nodes?.arcs?.length) { for (const arc of options.nodes.arcs) { // As the arc__ field suffixes can be custom we compare them case insensitively to be safe. const field = frame.fields.find((field) => field.name.toLowerCase() === arc.field?.toLowerCase()); if (field && arc.color) { field.config = { ...field.config, color: { fixedColor: arc.color, mode: FieldColorModeId.Fixed } }; } } } } return frame; }); }; // Returns an array of node ids which are connected to a given edge export const findConnectedNodesForEdge = (nodes: NodeDatum[], edges: EdgeDatum[], edgeId: string): string[] => { const edge = edges.find((edge) => edge.id === edgeId); if (edge) { return [ ...new Set(nodes.filter((node) => edge.source === node.id || edge.target === node.id).map((node) => node.id)), ]; } return []; }; // Returns an array of node ids which are connected to a given node export const findConnectedNodesForNode = (nodes: NodeDatum[], edges: EdgeDatum[], nodeId: string): string[] => { const node = nodes.find((node) => node.id === nodeId); if (node) { const linkedEdges = edges.filter((edge) => edge.source === node.id || edge.target === node.id); return [ ...new Set( linkedEdges.flatMap((edge) => nodes.filter((n) => edge.source === n.id || edge.target === n.id).map((n) => n.id) ) ), ]; } return []; }; export const getGraphFrame = (frames: DataFrame[]) => { return frames.reduce<GraphFrame>( (acc, frame) => { const sourceField = frame.fields.filter((f) => f.name === 'source'); if (frame.name === 'edges' || sourceField.length) { acc.edges.push(frame); } else { acc.nodes.push(frame); } return acc; }, { edges: [], nodes: [] } ); };
.
Edit
..
Edit
Edge.tsx
Edit
EdgeArrowMarker.tsx
Edit
EdgeLabel.tsx
Edit
Legend.test.tsx
Edit
Legend.tsx
Edit
Marker.tsx
Edit
Node.test.tsx
Edit
Node.tsx
Edit
NodeGraph.test.tsx
Edit
NodeGraph.tsx
Edit
NodeGraphPanel.tsx
Edit
README.md
Edit
ViewControls.tsx
Edit
createLayoutWorker.ts
Edit
editor
Edit
forceLayout.js
Edit
img
Edit
layeredLayout.js
Edit
layeredLayout.test.ts
Edit
layeredLayout.worker.js
Edit
layout.test.ts
Edit
layout.ts
Edit
layout.worker.js
Edit
module.tsx
Edit
panelcfg.cue
Edit
panelcfg.gen.ts
Edit
plugin.json
Edit
suggestions.ts
Edit
types.ts
Edit
useCategorizeFrames.ts
Edit
useContextMenu.tsx
Edit
useFocusPositionOnLayout.ts
Edit
useHighlight.ts
Edit
useNodeLimit.ts
Edit
usePanning.ts
Edit
useZoom.ts
Edit
utils.test.ts
Edit
utils.ts
Edit