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', + // 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__', - arc = 'arc__', - color = 'color', } 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 ( + ); +} + +function HeaderRow({ label, value }: { label: string; value: string }) { + const styles = useStyles2(getLabelStyles); + return ( + + {label}: + {value} + + ); +} + +/** + * Shows some field values in a table on top of the context menu. + */ function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) { - const index = node.dataFrameRowIndex; + const rows = []; if (nodes) { const fields = getNodeFields(nodes); - - return ( -
- {fields.title && ( -
- ); + 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. - return ( -
- {node.title &&
- ); + 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 ( -
-
{label}
- {value} -
+ + {rows} +
); } @@ -254,19 +253,16 @@ export const getLabelStyles = (theme: GrafanaTheme2) => { label: css` label: Label; line-height: 1.25; - margin-bottom: ${theme.spacing(0.5)}; - padding-left: ${theme.spacing(0.25)}; color: ${theme.colors.text.disabled}; font-size: ${theme.typography.size.sm}; font-weight: ${theme.typography.fontWeightMedium}; + padding-right: ${theme.spacing(1)}; `, value: css` label: Value; font-size: ${theme.typography.size.sm}; font-weight: ${theme.typography.fontWeightMedium}; color: ${theme.colors.text.primary}; - margin-top: ${theme.spacing(0.25)}; - display: block; `, }; }; diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts index 4049077d07d..b7c1d002a14 100644 --- a/public/app/plugins/panel/nodeGraph/utils.test.ts +++ b/public/app/plugins/panel/nodeGraph/utils.test.ts @@ -311,6 +311,7 @@ function makeNodeDatum(options: Partial = {}) { }, subTitle: 'service', title: 'service:0', + icon: 'database', ...options, }; } diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index bb931502a27..f0eec22798c 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -45,6 +45,7 @@ export type NodeFields = { arc: Field[]; details: Field[]; color?: Field; + icon?: Field; }; export function getNodeFields(nodes: DataFrame): NodeFields { @@ -62,6 +63,7 @@ export function getNodeFields(nodes: DataFrame): NodeFields { arc: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.arc), details: findFieldsByPrefix(nodes, NodeGraphDataFrameFieldNames.detail), color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color), + icon: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.icon), }; } @@ -287,17 +289,18 @@ function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge { }; } -function makeNodeDatum(id: string, nodeFields: NodeFields, index: number) { +function makeNodeDatum(id: string, nodeFields: NodeFields, index: number): NodeDatum { return { id: id, title: nodeFields.title?.values.get(index) || '', - subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '', + subTitle: nodeFields.subTitle?.values.get(index) || '', dataFrameRowIndex: index, incoming: 0, mainStat: nodeFields.mainStat, secondaryStat: nodeFields.secondaryStat, arcSections: nodeFields.arc, color: nodeFields.color, + icon: nodeFields.icon?.values.get(index) || '', }; } @@ -337,6 +340,7 @@ function makeNode(index: number) { mainstat: 0.1, secondarystat: 2, color: 0.5, + icon: 'database', }; } @@ -372,12 +376,15 @@ function nodesFrame() { type: FieldType.number, config: { color: { fixedColor: 'red' } }, }, - [NodeGraphDataFrameFieldNames.color]: { values: new ArrayVector(), type: FieldType.number, config: { color: { mode: 'continuous-GrYlRd' } }, }, + [NodeGraphDataFrameFieldNames.icon]: { + values: new ArrayVector(), + type: FieldType.string, + }, }; return new MutableDataFrame({