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,
};
}