From b8d005ae236302f485cff0dc9fd4eaacf0058fbc Mon Sep 17 00:00:00 2001 From: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com> Date: Tue, 24 Oct 2023 15:16:10 +0200 Subject: [PATCH] Plugins: Improvements to NodeGraph (#76879) --- .betterer.results | 3 +- .../visualizations/node-graph/index.md | 3 + packages/grafana-data/src/utils/nodeGraph.ts | 7 ++ .../nodeGraphUtils.ts | 12 ++- public/app/plugins/panel/nodeGraph/Edge.tsx | 78 +++++++++++-------- .../panel/nodeGraph/EdgeArrowMarker.tsx | 22 ++++-- .../app/plugins/panel/nodeGraph/Node.test.tsx | 1 + public/app/plugins/panel/nodeGraph/Node.tsx | 21 +++-- .../app/plugins/panel/nodeGraph/NodeGraph.tsx | 2 - .../plugins/panel/nodeGraph/layout.test.ts | 3 + public/app/plugins/panel/nodeGraph/types.ts | 3 + .../app/plugins/panel/nodeGraph/utils.test.ts | 4 + public/app/plugins/panel/nodeGraph/utils.ts | 31 +++++++- 13 files changed, 138 insertions(+), 52 deletions(-) diff --git a/.betterer.results b/.betterer.results index b5af32e93b2..e3f389eba41 100644 --- a/.betterer.results +++ b/.betterer.results @@ -7367,7 +7367,8 @@ exports[`better eslint`] = { [0, 0, 0, "Styles should be written using objects.", "4"], [0, 0, 0, "Styles should be written using objects.", "5"], [0, 0, 0, "Styles should be written using objects.", "6"], - [0, 0, 0, "Styles should be written using objects.", "7"] + [0, 0, 0, "Styles should be written using objects.", "7"], + [0, 0, 0, "Styles should be written using objects.", "8"] ], "public/app/plugins/panel/nodeGraph/NodeGraph.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md index d4ac4fc5d4a..7e7a3d2c588 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -108,6 +108,8 @@ Optional fields: | mainstat | string/number | First stat shown in the overlay when hovering over the edge. It can be a string showing the value as is or it can be a number. If it is a number, any unit associated with that field is also shown | | secondarystat | string/number | Same as mainStat, but shown right under it. | | detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the edge. Use `config.displayName` for more human readable label. | +| thickness | number | The thickness of the edge. Default: `1` | +| highlighted | boolean | Sets whether the edge should be highlighted. Useful, for example, to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | ### Nodes data frame structure @@ -130,3 +132,4 @@ Optional fields: | color | string/number | Can be used to specify a single color instead of using the `arc__` fields to specify color sections. It can be either a string which should then be an acceptable HTML color string or it can be a number in which case the behaviour depends on `field.config.color.mode` setting. This can be for example used to create gradient colors controlled by the field value. | | icon | string | Name of the icon to show inside the node instead of the default stats. Only Grafana built in icons are allowed (see the available icons [here](https://developers.grafana.com/ui/latest/index.html?path=/story/docs-overview-icon--icons-overview)). | | nodeRadius | number | Radius value in pixels. Used to manage node size. | +| highlighted | boolean | Sets whether the node should be highlighted. Useful for example to represent a specific path in the graph by highlighting several nodes and edges. Default: `false` | diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts index ce063f6fa43..b846348a7a7 100644 --- a/packages/grafana-data/src/utils/nodeGraph.ts +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -26,5 +26,12 @@ export enum NodeGraphDataFrameFieldNames { // Prefix for fields which will be shown in a context menu [nodes + edges] detail = 'detail__', + // Radius of the node [nodes] nodeRadius = 'noderadius', + + // Thickness of the edge [edges] + thickness = 'thickness', + + // Whether the node or edge should be highlighted (e.g., shown in red) in the UI + highlighted = 'highlighted', } diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts index cd74289ba7f..3bf1af6280b 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts @@ -105,6 +105,10 @@ export function generateRandomNodes(count = 10) { values: [], type: FieldType.number, }, + [NodeGraphDataFrameFieldNames.highlighted]: { + values: [], + type: FieldType.boolean, + }, }; const nodeFrame = new MutableDataFrame({ @@ -123,6 +127,8 @@ export function generateRandomNodes(count = 10) { { name: NodeGraphDataFrameFieldNames.source, values: [], type: FieldType.string, config: {} }, { name: NodeGraphDataFrameFieldNames.target, values: [], type: FieldType.string, config: {} }, { name: NodeGraphDataFrameFieldNames.mainStat, values: [], type: FieldType.number, config: {} }, + { name: NodeGraphDataFrameFieldNames.highlighted, values: [], type: FieldType.boolean, config: {} }, + { name: NodeGraphDataFrameFieldNames.thickness, values: [], type: FieldType.number, config: {} }, ], meta: { preferredVisualisationType: 'nodeGraph' }, length: 0, @@ -139,7 +145,8 @@ export function generateRandomNodes(count = 10) { nodeFields.arc__errors.values.push(node.error); const rnd = Math.random(); nodeFields[NodeGraphDataFrameFieldNames.icon].values.push(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : ''); - nodeFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(rnd > 0.5 ? 30 : 40); + nodeFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(Math.max(rnd * 100, 30)); // ensure a minimum radius of 30 or icons will not fit well in the node + nodeFields[NodeGraphDataFrameFieldNames.highlighted].values.push(Math.random() > 0.5); for (const edge of node.edges) { const id = `${node.id}--${edge}`; @@ -152,6 +159,8 @@ export function generateRandomNodes(count = 10) { edgesFrame.fields[1].values.push(node.id); edgesFrame.fields[2].values.push(edge); edgesFrame.fields[3].values.push(Math.random() * 100); + edgesFrame.fields[4].values.push(Math.random() > 0.5); + edgesFrame.fields[5].values.push(Math.ceil(Math.random() * 15)); } } edgesFrame.length = edgesFrame.fields[0].values.length; @@ -171,6 +180,7 @@ function makeRandomNode(index: number) { stat1: Math.random(), stat2: Math.random(), edges: [], + highlighted: Math.random() > 0.5, }; } diff --git a/public/app/plugins/panel/nodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx index ae2e7572b1e..b31c49be490 100644 --- a/public/app/plugins/panel/nodeGraph/Edge.tsx +++ b/public/app/plugins/panel/nodeGraph/Edge.tsx @@ -1,9 +1,13 @@ import React, { MouseEvent, memo } from 'react'; -import { nodeR } from './Node'; +import { EdgeArrowMarker } from './EdgeArrowMarker'; +import { computeNodeCircumferenceStrokeWidth, nodeR } from './Node'; import { EdgeDatum, NodeDatum } from './types'; import { shortenLine } from './utils'; +export const highlightedEdgeColor = '#a00'; +export const defaultEdgeColor = '#999'; + interface Props { edge: EdgeDatum; hovering: boolean; @@ -11,6 +15,7 @@ interface Props { onMouseEnter: (id: string) => void; onMouseLeave: (id: string) => void; } + export const Edge = memo(function Edge(props: Props) { const { edge, onClick, onMouseEnter, onMouseLeave, hovering } = props; @@ -21,6 +26,7 @@ export const Edge = memo(function Edge(props: Props) { sourceNodeRadius: number; targetNodeRadius: number; }; + const arrowHeadHeight = 10 + edge.thickness * 2; // resized value, just to make the UI nicer // As the nodes have some radius we want edges to end outside of the node circle. const line = shortenLine( @@ -30,39 +36,47 @@ export const Edge = memo(function Edge(props: Props) { x2: target.x!, y2: target.y!, }, - sourceNodeRadius || nodeR, - targetNodeRadius || nodeR + sourceNodeRadius + computeNodeCircumferenceStrokeWidth(sourceNodeRadius) / 2 || nodeR, + targetNodeRadius + computeNodeCircumferenceStrokeWidth(targetNodeRadius) / 2 || nodeR, + arrowHeadHeight ); + const markerId = `triangle-${edge.id}`; + const coloredMarkerId = `triangle-colored-${edge.id}`; + return ( - onClick(event, edge)} - style={{ cursor: 'pointer' }} - aria-label={`Edge from: ${source.id} to: ${target.id}`} - > - - { - onMouseEnter(edge.id); - }} - onMouseLeave={() => { - onMouseLeave(edge.id); - }} - /> - + <> + + + onClick(event, edge)} + style={{ cursor: 'pointer' }} + aria-label={`Edge from: ${source.id} to: ${target.id}`} + > + + { + onMouseEnter(edge.id); + }} + onMouseLeave={() => { + onMouseLeave(edge.id); + }} + /> + + ); }); diff --git a/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx b/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx index a56a97c85d3..ac53e42a78b 100644 --- a/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx +++ b/public/app/plugins/panel/nodeGraph/EdgeArrowMarker.tsx @@ -1,23 +1,33 @@ import React from 'react'; +import { defaultEdgeColor } from './Edge'; + /** * In SVG you need to supply this kind of marker that can be then referenced from a line segment as an ending of the * line turning in into arrow. Needs to be included in the svg element and then referenced as markerEnd="url(#triangle)" */ -export function EdgeArrowMarker() { +export function EdgeArrowMarker({ + id = 'triangle', + fill = defaultEdgeColor, + headHeight = 10, +}: { + id?: string; + fill?: string; + headHeight?: number; +}) { return ( - + ); diff --git a/public/app/plugins/panel/nodeGraph/Node.test.tsx b/public/app/plugins/panel/nodeGraph/Node.test.tsx index a831e673b6b..c6fe4e91750 100644 --- a/public/app/plugins/panel/nodeGraph/Node.test.tsx +++ b/public/app/plugins/panel/nodeGraph/Node.test.tsx @@ -69,4 +69,5 @@ const nodeDatum = { mainStat: { name: 'stat', values: [1234], type: FieldType.number, config: {} }, secondaryStat: { name: 'stat2', values: [9876], type: FieldType.number, config: {} }, arcSections: [], + highlighted: false, }; diff --git a/public/app/plugins/panel/nodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx index f111a8de786..f87da31b7ac 100644 --- a/public/app/plugins/panel/nodeGraph/Node.tsx +++ b/public/app/plugins/panel/nodeGraph/Node.tsx @@ -11,6 +11,7 @@ import { NodeDatum } from './types'; import { statToString } from './utils'; export const nodeR = 40; +export const highlightedNodeColor = '#a00'; const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({ mainGroup: css` @@ -24,6 +25,10 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({ fill: ${theme.components.panel.background}; `, + filledCircle: css` + fill: ${highlightedNodeColor}; + `, + hoverCircle: css` opacity: 0.5; fill: transparent; @@ -66,6 +71,8 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({ `, }); +export const computeNodeCircumferenceStrokeWidth = (nodeRadius: number) => Math.ceil(nodeRadius * 0.075); + export const Node = memo(function Node(props: { node: NodeDatum; hovering: HoverState; @@ -78,6 +85,7 @@ export const Node = memo(function Node(props: { const styles = getStyles(theme, hovering); const isHovered = hovering === 'active'; const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR; + const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius); if (!(node.x !== undefined && node.y !== undefined)) { return null; @@ -87,13 +95,13 @@ export const Node = memo(function Node(props: { {isHovered && ( - + )} @@ -172,14 +180,15 @@ function ColorCircle(props: { node: NodeDatum }) { const fullStat = node.arcSections.find((s) => s.values[node.dataFrameRowIndex] >= 1); const theme = useTheme2(); const nodeRadius = node.nodeRadius?.values[node.dataFrameRowIndex] || nodeR; + const strokeWidth = computeNodeCircumferenceStrokeWidth(nodeRadius); if (fullStat) { - // Doing arc with path does not work well so it's better to just do a circle in that case + // Drawing a full circle with a `path` tag does not work well, it's better to use a `circle` tag in that case return ( ); acc.elements.push(el); diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index b029c242af3..ccd14fd12b3 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -7,7 +7,6 @@ import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; import { Icon, Spinner, useStyles2 } from '@grafana/ui'; import { Edge } from './Edge'; -import { EdgeArrowMarker } from './EdgeArrowMarker'; import { EdgeLabel } from './EdgeLabel'; import { Legend } from './Legend'; import { Marker } from './Marker'; @@ -208,7 +207,6 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { className={styles.mainGroup} style={{ transform: `scale(${scale}) translate(${Math.floor(position.x)}px, ${Math.floor(position.y)}px)` }} > - {!config.gridLayout && ( = {}) { ], color: colorField, dataFrameRowIndex: 0, + highlighted: false, id: '0', incoming: 0, mainStat: { @@ -334,6 +335,8 @@ function makeEdgeDatum(id: string, index: number, mainStat = '', secondaryStat = target: id.split('--')[1], sourceNodeRadius: 40, targetNodeRadius: 40, + highlighted: false, + thickness: 1, }; } @@ -346,5 +349,6 @@ function makeNodeFromEdgeDatum(options: Partial = {}): NodeDatum { subTitle: '', title: 'service:0', ...options, + highlighted: false, }; } diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index 695ecdf523c..97fb11e3061 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -15,19 +15,32 @@ import { EdgeDatum, GraphFrame, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } type Line = { x1: number; y1: number; x2: number; y2: number }; /** - * Makes line shorter while keeping the middle in he same place. + * 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): Line { +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 + 5), - y2: line.y2 - sine * (targetNodeRadius + 5), + x2: line.x2 - cosine * (targetNodeRadius + 3 + scaledThickness), + y2: line.y2 - sine * (targetNodeRadius + 3 + scaledThickness), }; } @@ -42,6 +55,7 @@ export type NodeFields = { color?: Field; icon?: Field; nodeRadius?: Field; + highlighted?: Field; }; export function getNodeFields(nodes: DataFrame): NodeFields { @@ -61,6 +75,7 @@ export function getNodeFields(nodes: DataFrame): NodeFields { color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color), icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon), nodeRadius: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.nodeRadius.toLowerCase()), + highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()), }; } @@ -71,6 +86,8 @@ export type EdgeFields = { mainStat?: Field; secondaryStat?: Field; details: Field[]; + highlighted?: Field; + thickness?: Field; }; export function getEdgeFields(edges: DataFrame): EdgeFields { @@ -86,6 +103,8 @@ export function getEdgeFields(edges: DataFrame): EdgeFields { mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()), secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()), details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()), + highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()), + thickness: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.thickness.toLowerCase()), }; } @@ -215,6 +234,8 @@ function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id: secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index]) : '', + highlighted: edgeFields.highlighted?.values[index] || false, + thickness: edgeFields.thickness?.values[index] || 1, }; }); } @@ -286,6 +307,7 @@ function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge { dataFrameRowIndex: index, incoming: 0, arcSections: [], + highlighted: false, }; } @@ -302,6 +324,7 @@ function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeD color: nodeFields.color, icon: nodeFields.icon?.values[index] || '', nodeRadius: nodeFields.nodeRadius, + highlighted: nodeFields.highlighted?.values[index] || false, }; }