diff --git a/.betterer.results b/.betterer.results index c79fb67538c..cb77689214a 100644 --- a/.betterer.results +++ b/.betterer.results @@ -6837,8 +6837,7 @@ exports[`better eslint`] = { "public/app/plugins/datasource/testdata/nodeGraphUtils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"], - [0, 0, 0, "Unexpected any. Specify a different type.", "2"], - [0, 0, 0, "Unexpected any. Specify a different type.", "3"] + [0, 0, 0, "Unexpected any. Specify a different type.", "2"] ], "public/app/plugins/datasource/testdata/runStreams.ts:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], diff --git a/docs/sources/panels-visualizations/visualizations/node-graph/index.md b/docs/sources/panels-visualizations/visualizations/node-graph/index.md index 83bd5ccdc04..d8448b40f6c 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -85,9 +85,27 @@ Click on the node and select "Show in Graph layout" option to switch back to gra This visualization needs a specific shape of the data to be returned from the data source in order to correctly display it. -Data source needs to return two data frames, one for nodes and one for edges. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render. +Node Graph at minimum requires a data frame describing the edges of the graph. By default, node graph will compute the nodes and any stats based on this data frame. Optionally a second data frame describing the nodes can be sent in case there is need to show more node specific metadata. You have to set `frame.meta.preferredVisualisationType = 'nodeGraph'` on both data frames or name them `nodes` and `edges` respectively for the node graph to render. -### Node parameters +### Edges data frame structure + +Required fields: + +| Field name | Type | Description | +| ---------- | ------ | ------------------------------ | +| id | string | Unique identifier of the edge. | +| source | string | Id of the source node. | +| target | string | Id of the target. | + +Optional fields: + +| Field name | Type | Description | +| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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. | + +### Nodes data frame structure Required fields: @@ -105,21 +123,3 @@ Optional fields: | 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. | - -### Edge parameters - -Required fields: - -| Field name | Type | Description | -| ---------- | ------ | ------------------------------ | -| id | string | Unique identifier of the edge. | -| source | string | Id of the source node. | -| target | string | Id of the target. | - -Optional fields: - -| Field name | Type | Description | -| ------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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. | diff --git a/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx b/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx index 398b3f8de23..2a7df842c3d 100644 --- a/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx +++ b/public/app/plugins/datasource/testdata/components/NodeGraphEditor.tsx @@ -23,7 +23,7 @@ export function NodeGraphEditor({ query, onChange }: Props) { width={32} /> - {type === 'random' && ( + {(type === 'random' || type === 'random edges') && ( = ['random', 'response']; +const options: Array = ['random', 'response', 'random edges']; diff --git a/public/app/plugins/datasource/testdata/datasource.ts b/public/app/plugins/datasource/testdata/datasource.ts index fd08daf5273..fd9e42ea665 100644 --- a/public/app/plugins/datasource/testdata/datasource.ts +++ b/public/app/plugins/datasource/testdata/datasource.ts @@ -19,7 +19,7 @@ import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv import { getSearchFilterScopedVar } from 'app/features/variables/utils'; import { queryMetricTree } from './metricTree'; -import { generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; +import { generateRandomEdges, generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; import { runStream } from './runStreams'; import { flameGraphData } from './testData/flameGraphResponse'; import { Scenario, TestDataQuery } from './types'; @@ -210,6 +210,9 @@ export class TestDataDataSource extends DataSourceWithBackend { case 'response': frames = savedNodesResponse(); break; + case 'random edges': + frames = [generateRandomEdges(target.nodes?.count)]; + break; default: throw new Error(`Unknown node_graph sub type ${type}`); } diff --git a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts index 894e5652401..c4b5b86448f 100644 --- a/public/app/plugins/datasource/testdata/nodeGraphUtils.ts +++ b/public/app/plugins/datasource/testdata/nodeGraphUtils.ts @@ -13,7 +13,7 @@ export function generateRandomNodes(count = 10) { const nodes = []; const root = { - id: '0', + id: 'root', title: 'root', subTitle: 'client', success: 1, @@ -44,11 +44,11 @@ export function generateRandomNodes(count = 10) { for (let i = 0; i <= additionalEdges; i++) { const sourceIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); const targetIndex = Math.floor(Math.random() * Math.floor(nodes.length - 1)); - if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[sourceIndex].id === '0') { + if (sourceIndex === targetIndex || nodes[sourceIndex].id === '0' || nodes[targetIndex].id === '0') { continue; } - nodes[sourceIndex].edges.push(nodes[sourceIndex].id); + nodes[sourceIndex].edges.push(nodes[targetIndex].id); } const nodeFields: Record & { values: ArrayVector }> = { @@ -108,27 +108,14 @@ export function generateRandomNodes(count = 10) { meta: { preferredVisualisationType: 'nodeGraph' }, }); - const edgeFields: any = { - [NodeGraphDataFrameFieldNames.id]: { - values: new ArrayVector(), - type: FieldType.string, - }, - [NodeGraphDataFrameFieldNames.source]: { - values: new ArrayVector(), - type: FieldType.string, - }, - [NodeGraphDataFrameFieldNames.target]: { - values: new ArrayVector(), - type: FieldType.string, - }, - }; - const edgesFrame = new MutableDataFrame({ name: 'edges', - fields: Object.keys(edgeFields).map((key) => ({ - ...edgeFields[key], - name: key, - })), + fields: [ + { name: NodeGraphDataFrameFieldNames.id, values: new ArrayVector(), type: FieldType.string }, + { name: NodeGraphDataFrameFieldNames.source, values: new ArrayVector(), type: FieldType.string }, + { name: NodeGraphDataFrameFieldNames.target, values: new ArrayVector(), type: FieldType.string }, + { name: NodeGraphDataFrameFieldNames.mainStat, values: new ArrayVector(), type: FieldType.number }, + ], meta: { preferredVisualisationType: 'nodeGraph' }, }); @@ -148,9 +135,10 @@ export function generateRandomNodes(count = 10) { continue; } edgesSet.add(id); - edgeFields.id.values.add(`${node.id}--${edge}`); - edgeFields.source.values.add(node.id); - edgeFields.target.values.add(edge); + edgesFrame.fields[0].values.add(`${node.id}--${edge}`); + edgesFrame.fields[1].values.add(node.id); + edgesFrame.fields[2].values.add(edge); + edgesFrame.fields[3].values.add(Math.random() * 100); } } @@ -161,7 +149,7 @@ function makeRandomNode(index: number) { const success = Math.random(); const error = 1 - success; return { - id: index.toString(), + id: `service:${index}`, title: `service:${index}`, subTitle: 'service', success, @@ -175,3 +163,8 @@ function makeRandomNode(index: number) { export function savedNodesResponse(): any { return [new MutableDataFrame(nodes), new MutableDataFrame(edges)]; } + +// Generates node graph data but only returns the edges +export function generateRandomEdges(count = 10) { + return generateRandomNodes(count)[1]; +} diff --git a/public/app/plugins/datasource/testdata/types.ts b/public/app/plugins/datasource/testdata/types.ts index 3e1d97ca488..82495788dcc 100644 --- a/public/app/plugins/datasource/testdata/types.ts +++ b/public/app/plugins/datasource/testdata/types.ts @@ -30,7 +30,7 @@ export interface TestDataQuery extends DataQuery { } export interface NodesQuery { - type?: 'random' | 'response'; + type?: 'random' | 'response' | 'random edges'; count?: number; } diff --git a/public/app/plugins/panel/nodeGraph/Node.tsx b/public/app/plugins/panel/nodeGraph/Node.tsx index 75b2b1f5f6d..498c843f462 100644 --- a/public/app/plugins/panel/nodeGraph/Node.tsx +++ b/public/app/plugins/panel/nodeGraph/Node.tsx @@ -96,9 +96,14 @@ export const Node = memo(function Node(props: {
- {node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)} + + {node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))} +
- {node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)} + + {node.secondaryStat && + statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))} +
{ }); it('can zoom in and out', async () => { - render( []} />); + render( + []} + /> + ); const zoomIn = await screen.findByTitle(/Zoom in/); const zoomOut = await screen.findByTitle(/Zoom out/); @@ -44,8 +49,8 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(3), makeEdgesDataFrame([ - [0, 1], - [1, 2], + { source: '0', target: '1' }, + { source: '1', target: '2' }, ]), ]} getLinks={() => []} @@ -70,7 +75,7 @@ describe('NodeGraph', () => { it('shows context menu when clicking on node or edge', async () => { render( { return [ { @@ -98,8 +103,8 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(3), makeEdgesDataFrame([ - [0, 1], - [1, 2], + { source: '0', target: '1' }, + { source: '1', target: '2' }, ]), ]} getLinks={() => []} @@ -117,8 +122,8 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(3), makeEdgesDataFrame([ - [0, 1], - [0, 2], + { source: '0', target: '1' }, + { source: '0', target: '2' }, ]), ]} getLinks={() => []} @@ -137,10 +142,10 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(5), makeEdgesDataFrame([ - [0, 1], - [0, 2], - [2, 3], - [3, 4], + { source: '0', target: '1' }, + { source: '0', target: '2' }, + { source: '2', target: '3' }, + { source: '3', target: '4' }, ]), ]} getLinks={() => []} @@ -162,10 +167,10 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(5), makeEdgesDataFrame([ - [0, 1], - [1, 2], - [2, 3], - [3, 4], + { source: '0', target: '1' }, + { source: '1', target: '2' }, + { source: '2', target: '3' }, + { source: '3', target: '4' }, ]), ]} getLinks={() => []} @@ -192,8 +197,8 @@ describe('NodeGraph', () => { dataFrames={[ makeNodesDataFrame(3), makeEdgesDataFrame([ - [0, 1], - [1, 2], + { source: '0', target: '1' }, + { source: '1', target: '2' }, ]), ]} getLinks={() => []} diff --git a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx index d4ea4365bf3..a42d64eab54 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraph.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraph.tsx @@ -4,7 +4,7 @@ import React, { memo, MouseEvent, useCallback, useEffect, useMemo, useState } fr import useMeasure from 'react-use/lib/useMeasure'; import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; -import { Icon, Spinner, useStyles2, useTheme2 } from '@grafana/ui'; +import { Icon, Spinner, useStyles2 } from '@grafana/ui'; import { Edge } from './Edge'; import { EdgeArrowMarker } from './EdgeArrowMarker'; @@ -123,13 +123,11 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { const firstNodesDataFrame = nodesDataFrames[0]; const firstEdgesDataFrame = edgesDataFrames[0]; - const theme = useTheme2(); - // TODO we should be able to allow multiple dataframes for both edges and nodes, could be issue with node ids which in // that case should be unique or figure a way to link edges and nodes dataframes together. const processed = useMemo( - () => processNodes(firstNodesDataFrame, firstEdgesDataFrame, theme), - [firstEdgesDataFrame, firstNodesDataFrame, theme] + () => processNodes(firstNodesDataFrame, firstEdgesDataFrame), + [firstEdgesDataFrame, firstNodesDataFrame] ); // We need hover state here because for nodes we also highlight edges and for edges have labels separate to make @@ -162,7 +160,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { focusedNodeId ); - // If we move from grid to graph layout and we have focused node lets get its position to center there. We want do + // If we move from grid to graph layout, and we have focused node lets get its position to center there. We want to // do it specifically only in that case. const focusPosition = useFocusPositionOnLayout(config, nodes, focusedNodeId); const { panRef, zoomRef, onStepUp, onStepDown, isPanning, position, scale, isMaxZoom, isMinZoom } = usePanAndZoom( @@ -180,7 +178,7 @@ export function NodeGraph({ getLinks, dataFrames, nodeLimit }: Props) { ); const styles = useStyles2(getStyles); - // This cannot be inline func or it will create infinite render cycle. + // This cannot be inline func, or it will create infinite render cycle. const topLevelRef = useCallback( (r: HTMLDivElement) => { measureRef(r); diff --git a/public/app/plugins/panel/nodeGraph/layout.ts b/public/app/plugins/panel/nodeGraph/layout.ts index 30e4b57a6d7..36c3e6beabd 100644 --- a/public/app/plugins/panel/nodeGraph/layout.ts +++ b/public/app/plugins/panel/nodeGraph/layout.ts @@ -199,7 +199,7 @@ function gridLayout( const val1 = sort!.field.values.get(node1.dataFrameRowIndex); const val2 = sort!.field.values.get(node2.dataFrameRowIndex); - // Lets pretend we don't care about type of the stats for a while (they can be strings) + // Let's pretend we don't care about type of the stats for a while (they can be strings) return sort!.ascending ? val1 - val2 : val2 - val1; }); } diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index 130a02923d6..b9f08b60913 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -35,6 +35,8 @@ export type NodeDatum = SimulationNodeDatum & { color?: Field; }; +export type NodeDatumFromEdge = NodeDatum & { mainStatNumeric?: number; secondaryStatNumeric?: number }; + // This is the data we have before the graph is laid out with source and target being string IDs. type LinkDatum = SimulationLinkDatum & { source: string; diff --git a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx index a8ab98ad701..cdc3cda0615 100644 --- a/public/app/plugins/panel/nodeGraph/useContextMenu.tsx +++ b/public/app/plugins/panel/nodeGraph/useContextMenu.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import React, { MouseEvent, useCallback, useState } from 'react'; -import { DataFrame, Field, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { DataFrame, GrafanaTheme2, LinkModel } from '@grafana/data'; import { ContextMenu, MenuGroup, MenuItem, useStyles2, useTheme2 } from '@grafana/ui'; import { Config } from './layout'; @@ -14,8 +14,10 @@ import { getEdgeFields, getNodeFields } from './utils'; */ export function useContextMenu( getLinks: (dataFrame: DataFrame, rowIndex: number) => LinkModel[], - nodes: DataFrame, - edges: DataFrame, + // This can be undefined if we only use edge dataframe + nodes: DataFrame | undefined, + // This can be undefined if we have only single node + edges: DataFrame | undefined, config: Config, setConfig: (config: Config) => void, setFocusedNodeId: (id: string) => void @@ -28,13 +30,9 @@ export function useContextMenu( const onNodeOpen = useCallback( (event: MouseEvent, node: NodeDatum) => { - let label = 'Show in Grid layout'; - let showGridLayout = true; - - if (config.gridLayout) { - label = 'Show in Graph layout'; - showGridLayout = false; - } + const [label, showGridLayout] = config.gridLayout + ? ['Show in Graph layout', false] + : ['Show in Grid layout', true]; const extraNodeItem = [ { @@ -47,18 +45,11 @@ export function useContextMenu( }, ]; - const renderer = getItemsRenderer(getLinks(nodes, node.dataFrameRowIndex), node, extraNodeItem); + const links = nodes ? getLinks(nodes, node.dataFrameRowIndex) : []; + const renderer = getItemsRenderer(links, node, extraNodeItem); if (renderer) { - setMenu( - } - renderMenuItems={renderer} - onClose={() => setMenu(undefined)} - x={event.pageX} - y={event.pageY} - /> - ); + setMenu(makeContextMenu(, renderer, event, setMenu)); } }, [config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId] @@ -66,18 +57,16 @@ export function useContextMenu( const onEdgeOpen = useCallback( (event: MouseEvent, edge: EdgeDatum) => { - const renderer = getItemsRenderer(getLinks(edges, edge.dataFrameRowIndex), edge); + if (!edges) { + // This could happen if we have only one node and no edges, in which case this is not needed as there is no edge + // to click on. + return; + } + const links = getLinks(edges, edge.dataFrameRowIndex); + const renderer = getItemsRenderer(links, edge); if (renderer) { - setMenu( - } - renderMenuItems={renderer} - onClose={() => setMenu(undefined)} - x={event.pageX} - y={event.pageY} - /> - ); + setMenu(makeContextMenu(, renderer, event, setMenu)); } }, [edges, getLinks, setMenu] @@ -86,6 +75,23 @@ export function useContextMenu( return { onEdgeOpen, onNodeOpen, MenuComponent: menu }; } +function makeContextMenu( + header: JSX.Element, + renderer: () => React.ReactNode, + event: MouseEvent, + setMenu: (el: JSX.Element | undefined) => void +) { + return ( + header} + renderMenuItems={renderer} + onClose={() => setMenu(undefined)} + x={event.pageX} + y={event.pageY} + /> + ); +} + function getItemsRenderer( links: LinkModel[], item: T, @@ -173,24 +179,45 @@ function getItems(links: LinkModel[]) { }); } -function NodeHeader(props: { node: NodeDatum; nodes: DataFrame }) { - const index = props.node.dataFrameRowIndex; - const fields = getNodeFields(props.nodes); - return ( -
- {fields.title &&
- ); +function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) { + const index = node.dataFrameRowIndex; + if (nodes) { + const fields = getNodeFields(nodes); + + return ( +
+ {fields.title && ( +
+ ); + } else { + // Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this. + return ( +
+ {node.title &&
+ ); + } } function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) { const index = props.edge.dataFrameRowIndex; - const fields = getEdgeFields(props.edges); const styles = getLabelStyles(useTheme2()); + const fields = getEdgeFields(props.edges); const valueSource = fields.source?.values.get(index) || ''; const valueTarget = fields.target?.values.get(index) || ''; @@ -205,20 +232,18 @@ function EdgeHeader(props: { edge: EdgeDatum; edges: DataFrame }) { )} {fields.details.map((f) => ( -