diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md
index 5bccff13f79..214aac392ef 100644
--- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md
+++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md
@@ -112,11 +112,13 @@ Required fields:
Optional fields:
-| Field name | Type | Description |
-| ------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| title | string | Name of the node visible in just under the node. |
-| subtitle | string | Additional, name, type or other identifier shown under the title. |
-| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
-| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
-| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
-| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
+| Field name | Type | Description |
+| ------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| title | string | Name of the node visible in just under the node. |
+| subtitle | string | Additional, name, type or other identifier shown under the title. |
+| mainstat | string/number | First stat shown inside the node itself. It can either be a string showing the value as is or a number. If it is a number, any unit associated with that field is also shown. |
+| secondarystat | string/number | Same as mainStat, but shown under it inside the node. |
+| arc\_\_\* | number | Any field prefixed with `arc__` will be used to create the color circle around the node. All values in these fields should add up to 1. You can specify color using `config.color.fixedColor`. |
+| detail\_\_\* | string/number | Any field prefixed with `detail__` will be shown in the header of context menu when clicked on the node. Use `config.displayName` for more human readable label. |
+| 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)). |
diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts
index caccc59e5ef..a366555e623 100644
--- a/packages/grafana-data/src/utils/nodeGraph.ts
+++ b/packages/grafana-data/src/utils/nodeGraph.ts
@@ -1,12 +1,28 @@
export enum NodeGraphDataFrameFieldNames {
+ // Unique identifier [required] [nodes + edges]
id = 'id',
+ // Text to show under the node [nodes]
title = 'title',
+ // Text to show under the node as second line [nodes]
subTitle = 'subtitle',
+ // Main value to be shown inside the node [nodes]
mainStat = 'mainstat',
+ // Second value to be shown inside the node under the mainStat [nodes]
secondaryStat = 'secondarystat',
- source = 'source',
- target = 'target',
- detail = 'detail__',
+ // Prefix for fields which value will represent part of the color circle around the node, values should add up to 1 [nodes]
arc = 'arc__',
+ // Will show a named icon inside the node circle if defined. Can be used only with icons already available in
+ // grafana/ui [nodes]
+ icon = 'icon',
+ // Defines a single color if string (hex or html named value) or color mode config can be used as threshold or
+ // gradient. arc__ fields must not be defined if used [nodes]
color = 'color',
+
+ // Id of the source node [required] [edges]
+ source = 'source',
+ // Id of the target node [required] [edges]
+ target = 'target',
+
+ // Prefix for fields which will be shown in a context menu [nodes + edges]
+ detail = 'detail__',
}
diff --git a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts
index 66ee06d53c4..1b1ffe5149c 100644
--- a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts
+++ b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts
@@ -97,6 +97,10 @@ export function generateRandomNodes(count = 10) {
type: FieldType.number,
config: { color: { fixedColor: 'red', mode: FieldColorModeId.Fixed }, displayName: 'Errors' },
},
+ [NodeGraphDataFrameFieldNames.icon]: {
+ values: new ArrayVector(),
+ type: FieldType.string,
+ },
};
const nodeFrame = new MutableDataFrame({
@@ -128,6 +132,8 @@ export function generateRandomNodes(count = 10) {
nodeFields[NodeGraphDataFrameFieldNames.secondaryStat].values.add(node.stat2);
nodeFields.arc__success.values.add(node.success);
nodeFields.arc__errors.values.add(node.error);
+ const rnd = Math.random();
+ nodeFields[NodeGraphDataFrameFieldNames.icon].values.add(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : '');
for (const edge of node.edges) {
const id = `${node.id}--${edge}`;
// We can have duplicate edges when we added some more by random
diff --git a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
index 03cebc01343..ef9569b1819 100644
--- a/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
+++ b/public/app/plugins/panel/nodeGraph/EdgeLabel.tsx
@@ -29,10 +29,10 @@ interface Props {
}
export const EdgeLabel = memo(function EdgeLabel(props: Props) {
const { edge } = props;
- // Not great typing but after we do layout these properties are full objects not just references
+ // Not great typing, but after we do layout these properties are full objects not just references
const { source, target } = edge as { source: NodeDatum; target: NodeDatum };
- // As the nodes have some radius we want edges to end outside of the node circle.
+ // As the nodes have some radius we want edges to end outside the node circle.
const line = shortenLine(
{
x1: source.x!,
@@ -49,15 +49,32 @@ export const EdgeLabel = memo(function EdgeLabel(props: Props) {
};
const styles = useStyles2(getStyles);
+ const stats = [edge.mainStat, edge.secondaryStat].filter((x) => x);
+ const height = stats.length > 1 ? '30' : '15';
+ const middleOffset = stats.length > 1 ? 15 : 7.5;
+ let offset = stats.length > 1 ? -5 : 2.5;
+
+ const contents: JSX.Element[] = [];
+ stats.forEach((stat, index) => {
+ contents.push(
+
+ {stat}
+
+ );
+ offset += 15;
+ });
+
return (
-
-
- {edge.mainStat}
-
-
- {edge.secondaryStat}
-
+
+ {contents}
);
});
diff --git a/public/app/plugins/panel/nodeGraph/Node.test.tsx b/public/app/plugins/panel/nodeGraph/Node.test.tsx
new file mode 100644
index 00000000000..417040685e9
--- /dev/null
+++ b/public/app/plugins/panel/nodeGraph/Node.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react';
+import React from 'react';
+
+import { ArrayVector, FieldType } from '@grafana/data/src';
+
+import { Node } from './Node';
+
+describe('Node', () => {
+ it('renders correct data', async () => {
+ render(
+
+ );
+
+ expect(screen.getByText('node title')).toBeInTheDocument();
+ expect(screen.getByText('node subtitle')).toBeInTheDocument();
+ expect(screen.getByText('1234.00')).toBeInTheDocument();
+ expect(screen.getByText('9876.00')).toBeInTheDocument();
+ });
+
+ it('renders icon', async () => {
+ render(
+
+ );
+
+ expect(screen.getByTestId('node-icon-database')).toBeInTheDocument();
+ });
+});
+
+const nodeDatum = {
+ x: 0,
+ y: 0,
+ id: '1',
+ title: 'node title',
+ subTitle: 'node subtitle',
+ dataFrameRowIndex: 0,
+ incoming: 0,
+ mainStat: { name: 'stat', values: new ArrayVector([1234]), type: FieldType.number, config: {} },
+ secondaryStat: { name: 'stat2', values: new ArrayVector([9876]), type: FieldType.number, config: {} },
+ arcSections: [],
+};
diff --git a/public/app/plugins/panel/nodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx
index 498c843f462..a1c3b0dcd7b 100644
--- a/public/app/plugins/panel/nodeGraph/Node.tsx
+++ b/public/app/plugins/panel/nodeGraph/Node.tsx
@@ -4,7 +4,7 @@ import React, { MouseEvent, memo } from 'react';
import tinycolor from 'tinycolor2';
import { Field, getFieldColorModeForField, GrafanaTheme2 } from '@grafana/data';
-import { useTheme2 } from '@grafana/ui';
+import { Icon, useTheme2 } from '@grafana/ui';
import { HoverState } from './NodeGraph';
import { NodeDatum } from './types';
@@ -61,10 +61,10 @@ const getStyles = (theme: GrafanaTheme2, hovering: HoverState) => ({
export const Node = memo(function Node(props: {
node: NodeDatum;
+ hovering: HoverState;
onMouseEnter: (id: string) => void;
onMouseLeave: (id: string) => void;
onClick: (event: MouseEvent, node: NodeDatum) => void;
- hovering: HoverState;
}) {
const { node, onMouseEnter, onMouseLeave, onClick, hovering } = props;
const theme = useTheme2();
@@ -94,18 +94,7 @@ export const Node = memo(function Node(props: {
{isHovered && }
-
-
+ );
+}
+
+/**
+ * Shows some field values in a table on top of the context menu.
+ */
+function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
+ const rows = [];
+ if (nodes) {
+ const fields = getNodeFields(nodes);
+ for (const f of [fields.title, fields.subTitle, fields.mainStat, fields.secondaryStat, ...fields.details]) {
+ if (f && f.values.get(node.dataFrameRowIndex)) {
+ rows.push();
+ }
+ }
+ } else {
+ // Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this.
+ if (node.title) {
+ rows.push();
+ }
+ if (node.subTitle) {
+ rows.push();
+ }
+ }
+
+ return (
+
+ {rows}
+
+ );
+}
+
+/**
+ * Shows some of the field values in a table on top of the context menu.
+ */
function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) {
const index = props.edge.dataFrameRowIndex;
- const styles = getLabelStyles(useTheme2());
const fields = getEdgeFields(props.edges);
const valueSource = fields.source?.values.get(index) || '';
const valueTarget = fields.target?.values.get(index) || '';
- return (
-
- {fields.source && fields.target && (
-
-
Source → Target
-
- {valueSource} → {valueTarget}
-
-
- )}
- {fields.details.map((f) => (
-
- ))}
-
- );
-}
+ const rows = [];
+ if (valueSource && valueTarget) {
+ rows.push();
+ }
-function Label({ label, value }: { label: string; value: string | number }) {
- const styles = useStyles2(getLabelStyles);
+ for (const f of [fields.mainStat, fields.secondaryStat, ...fields.details]) {
+ if (f && f.values.get(index)) {
+ rows.push();
+ }
+ }
return (
-