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( + + {}} + onMouseLeave={() => {}} + onClick={() => {}} + hovering={'default'} + /> + + ); + + 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( + + {}} + onMouseLeave={() => {}} + onClick={() => {}} + hovering={'default'} + /> + + ); + + 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 && } - -
- - {node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))} - -
- - {node.secondaryStat && - statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))} - -
-
+ +
+ +
+
+ ) : ( + +
+ + {node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))} + +
+ + {node.secondaryStat && + statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))} + +
+
+ ); +} + /** * Shows the outer segmented circle with different colors based on the supplied data. */ @@ -164,13 +187,13 @@ function ColorCircle(props: { node: NodeDatum }) { elements: React.ReactNode[]; percent: number; }>( - (acc, section) => { + (acc, section, index) => { const color = section.config.color?.fixedColor || ''; const value = section.values.get(node.dataFrameRowIndex); const el = ( { }); describe('NodeGraph', () => { - const origError = console.error; - const consoleErrorMock = jest.fn(); - afterEach(() => (console.error = origError)); - beforeEach(() => (console.error = consoleErrorMock)); - it('shows no data message without any data', async () => { render( []} />); @@ -88,6 +83,12 @@ describe('NodeGraph', () => { }} /> ); + + // We mock this because for some reason the simulated click events don't have pageX/Y values resulting in some NaNs + // for positioning and this creates a warning message. + const origError = console.error; + console.error = jest.fn(); + const node = await screen.findByLabelText(/Node: service:0/); await userEvent.click(node); await screen.findByText(/Node traces/); @@ -95,6 +96,7 @@ describe('NodeGraph', () => { const edge = await screen.findByLabelText(/Edge from/); await userEvent.click(edge); await screen.findByText(/Edge traces/); + console.error = origError; }); it('lays out 3 nodes in single line', async () => { diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index a42d64eab54..d8ec693359b 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -368,10 +368,13 @@ const EdgeLabels = memo(function EdgeLabels(props: EdgeLabelsProps) { return ( <> {props.edges.map((e, index) => { + // We show the edge label in case user hovers over the edge directly or if they hover over node edge is + // connected to. const shouldShow = (e.source as NodeDatum).id === props.nodeHoveringId || (e.target as NodeDatum).id === props.nodeHoveringId || props.edgeHoveringId === e.id; + const hasStats = e.mainStat || e.secondaryStat; return shouldShow && hasStats && ; })} diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index c9051d1ad4a..5af3c19a61a 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -1,6 +1,6 @@ import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force'; -import { Field } from '@grafana/data'; +import { Field, IconName } from '@grafana/data'; export { PanelOptions as NodeGraphOptions, ArcOption } from './panelcfg.gen'; @@ -14,6 +14,7 @@ export type NodeDatum = SimulationNodeDatum & { secondaryStat?: Field; arcSections: Field[]; color?: Field; + icon?: IconName; }; export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number }; diff --git a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx index cdc3cda0615..93210c32f7d 100644 --- a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -1,12 +1,12 @@ import { css } from '@emotion/css'; import React, { MouseEvent, useCallback, useState } from 'react'; -import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; -import { ContextMenu, MenuGroup, MenuItem, useStyles2, useTheme2 } from '@grafana/ui'; +import { DataFrame, Field, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { ContextMenu, MenuGroup, MenuItem, useStyles2 } from '@grafana/ui'; import { Config } from './layout'; import { EdgeDatum, NodeDatum } from './types'; -import { getEdgeFields, getNodeFields } from './utils'; +import { getEdgeFields, getNodeFields, statToString } from './utils'; /** * Hook that contains state of the context menu, both for edges and nodes and provides appropriate component when @@ -47,10 +47,7 @@ export function useContextMenu( const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : []; const renderer = getItemsRenderer(links, node, extraNodeItem); - - if (renderer) { - setMenu(makeContextMenu(, renderer, event, setMenu)); - } + setMenu(makeContextMenu(, event, setMenu, renderer)); }, [config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId] ); @@ -64,10 +61,7 @@ export function useContextMenu( } const links = getLinks(edges, edge.dataFrameRowIndex); const renderer = getItemsRenderer(links, edge); - - if (renderer) { - setMenu(makeContextMenu(, renderer, event, setMenu)); - } + setMenu(makeContextMenu(, event, setMenu, renderer)); }, [edges, getLinks, setMenu] ); @@ -77,9 +71,9 @@ export function useContextMenu( function makeContextMenu( header: JSX.Element, - renderer: () => React.ReactNode, event: MouseEvent, - setMenu: (el: JSX.Element | undefined) => void + setMenu: (el: JSX.Element | undefined) => void, + renderer?: () => React.ReactNode ) { return ( - {fields.title && ( -