diff --git a/.betterer.results b/.betterer.results index bd47ca293ff..d1176ccb0d3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4719,9 +4719,6 @@ exports[`better eslint`] = { [0, 0, 0, "Do not use any type assertions.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] ], - "public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "public/app/plugins/datasource/grafana-testdata-datasource/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 96fe3cb7bfa..eb28da6aeaf 100644 --- a/docs/sources/panels-visualizations/visualizations/node-graph/index.md +++ b/docs/sources/panels-visualizations/visualizations/node-graph/index.md @@ -104,13 +104,21 @@ Required fields: 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. | -| 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` | +| 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. | +| 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` | +| color | string | Sets the default color of the edge. It can be an acceptable HTML color string. Default: `#999` | +| strokeDasharray | string | Sets the pattern of dashes and gaps used to render the edge. If unset, a solid line is used as edge. For more information and examples, refer to the [`stroke-dasharray` MDN documentation](https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-dasharray). | + +{{< admonition type="caution" >}} +Starting with 10.5, `highlighted` is deprecated. +It will be removed in a future release. +Use `color` to indicate a highlighted edge state instead. +{{< /admonition >}} ### Nodes data frame structure diff --git a/packages/grafana-data/src/utils/nodeGraph.ts b/packages/grafana-data/src/utils/nodeGraph.ts index b846348a7a7..4cb05d054c3 100644 --- a/packages/grafana-data/src/utils/nodeGraph.ts +++ b/packages/grafana-data/src/utils/nodeGraph.ts @@ -15,7 +15,7 @@ export enum NodeGraphDataFrameFieldNames { // 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] + // gradient. arc__ fields must not be defined if used [nodes + edges] color = 'color', // Id of the source node [required] [edges] @@ -32,6 +32,10 @@ export enum NodeGraphDataFrameFieldNames { // Thickness of the edge [edges] thickness = 'thickness', - // Whether the node or edge should be highlighted (e.g., shown in red) in the UI + // Whether the node or edge should be highlighted (e.g., shown in red) in the UI [nodes + edges] + // @deprecated -- for edges use color instead highlighted = 'highlighted', + + // Defines the stroke dash array for the edge [edges]. See SVG strokeDasharray definition for syntax. + strokeDasharray = 'strokedasharray', } diff --git a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts index 329a6feafdc..b7f57fd7e2c 100644 --- a/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/testdata/dataquery/x/TestDataDataQuery_types.gen.ts @@ -75,7 +75,7 @@ export interface SimulationQuery { export interface NodesQuery { count?: number; seed?: number; - type?: ('random' | 'response_small' | 'response_medium' | 'random edges'); + type?: ('random' | 'response_small' | 'response_medium' | 'random edges' | 'feature_showcase'); } export interface USAQuery { diff --git a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go index 3013282e006..95fec94c962 100644 --- a/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/grafana-testdata-datasource/kinds/dataquery/types_dataquery_gen.go @@ -11,10 +11,11 @@ package dataquery // Defines values for NodesQueryType. const ( - NodesQueryTypeRandom NodesQueryType = "random" - NodesQueryTypeRandomEdges NodesQueryType = "random edges" - NodesQueryTypeResponseMedium NodesQueryType = "response_medium" - NodesQueryTypeResponseSmall NodesQueryType = "response_small" + NodesQueryTypeFeatureShowcase NodesQueryType = "feature_showcase" + NodesQueryTypeRandom NodesQueryType = "random" + NodesQueryTypeRandomEdges NodesQueryType = "random edges" + NodesQueryTypeResponseMedium NodesQueryType = "response_medium" + NodesQueryTypeResponseSmall NodesQueryType = "response_small" ) // Defines values for StreamingQueryType. diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx b/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx index 9e1c3d626ea..207b39fd522 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx +++ b/public/app/plugins/datasource/grafana-testdata-datasource/components/NodeGraphEditor.tsx @@ -54,4 +54,10 @@ export function NodeGraphEditor({ query, onChange }: Props) { ); } -const options: Array = ['random', 'response_small', 'response_medium', 'random edges']; +const options: Array = [ + 'random', + 'response_small', + 'response_medium', + 'random edges', + 'feature_showcase', +]; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue index da51680346b..b8e0d75d1cc 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue +++ b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.cue @@ -83,7 +83,7 @@ composableKinds: DataQuery: { } @cuetsy(kind="interface") #NodesQuery: { - type?: "random" | "response_small" | "response_medium" | "random edges" + type?: "random" | "response_small" | "response_medium" | "random edges" | "feature_showcase" count?: int64 seed?: int64 } @cuetsy(kind="interface") diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts index f7fc07ecb35..0d45320c196 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/dataquery.gen.ts @@ -73,7 +73,7 @@ export interface SimulationQuery { export interface NodesQuery { count?: number; seed?: number; - type?: ('random' | 'response_small' | 'response_medium' | 'random edges'); + type?: ('random' | 'response_small' | 'response_medium' | 'random edges' | 'feature_showcase'); } export interface USAQuery { diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts b/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts index 7c486ddacb8..d575ad29ba9 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/datasource.ts @@ -22,7 +22,7 @@ import { DataSourceWithBackend, getBackendSrv, getGrafanaLiveSrv, getTemplateSrv import { Scenario, TestDataDataQuery, TestDataQueryType } from './dataquery.gen'; import { queryMetricTree } from './metricTree'; -import { generateRandomEdges, generateRandomNodes, savedNodesResponse } from './nodeGraphUtils'; +import { generateRandomEdges, generateRandomNodes, generateShowcaseData, savedNodesResponse } from './nodeGraphUtils'; import { runStream } from './runStreams'; import { flameGraphData, flameGraphDataDiff } from './testData/flameGraphResponse'; import { TestDataVariableSupport } from './variables'; @@ -237,6 +237,9 @@ export class TestDataDataSource extends DataSourceWithBackend const type = target.nodes?.type || 'random'; let frames: DataFrame[]; switch (type) { + case 'feature_showcase': + frames = generateShowcaseData(); + break; case 'random': frames = generateRandomNodes(target.nodes?.count, target.nodes?.seed); break; diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts index c575d25b8e7..3811b618905 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts +++ b/public/app/plugins/datasource/grafana-testdata-datasource/nodeGraphUtils.ts @@ -7,6 +7,7 @@ import { MutableDataFrame, NodeGraphDataFrameFieldNames, DataFrame, + addRow, } from '@grafana/data'; import * as serviceMapResponseSmall from './testData/serviceMapResponse'; @@ -56,7 +57,74 @@ export function generateRandomNodes(count = 10, seed?: number) { nodes[sourceIndex].edges.push(nodes[targetIndex].id); } - const nodeFields: Record & { values: any[] }> = { + const { nodesFields, nodesFrame, edgesFrame } = makeDataFrames(); + + const edgesSet = new Set(); + for (const node of nodes) { + nodesFields.id.values.push(node.id); + nodesFields.title.values.push(node.title); + nodesFields[NodeGraphDataFrameFieldNames.subTitle].values.push(node.subTitle); + nodesFields[NodeGraphDataFrameFieldNames.mainStat].values.push(node.stat1); + nodesFields[NodeGraphDataFrameFieldNames.secondaryStat].values.push(node.stat2); + nodesFields.arc__success.values.push(node.success); + nodesFields.arc__errors.values.push(node.error); + const rnd = Math.random(); + nodesFields[NodeGraphDataFrameFieldNames.icon].values.push(rnd > 0.9 ? 'database' : rnd < 0.1 ? 'cloud' : ''); + nodesFields[NodeGraphDataFrameFieldNames.nodeRadius].values.push(Math.max(rnd * 100, 30)); // ensure a minimum radius of 30 or icons will not fit well in the node + nodesFields[NodeGraphDataFrameFieldNames.highlighted].values.push(Math.random() > 0.5); + + for (const edge of node.edges) { + const id = `${node.id}--${edge}`; + // We can have duplicate edges when we added some more by random + if (edgesSet.has(id)) { + continue; + } + edgesSet.add(id); + edgesFrame.fields[0].values.push(`${node.id}--${edge}`); + 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; + + return [nodesFrame, edgesFrame]; +} + +function makeRandomNode(index: number) { + const success = Math.random(); + const error = 1 - success; + return { + id: `service:${index}`, + title: `service:${index}`, + subTitle: 'service', + success, + error, + stat1: Math.random(), + stat2: Math.random(), + edges: [], + highlighted: Math.random() > 0.5, + }; +} + +export function savedNodesResponse(size: 'small' | 'medium'): [DataFrame, DataFrame] { + const response = size === 'small' ? serviceMapResponseSmall : serviceMapResponsMedium; + return [new MutableDataFrame(response.nodes), new MutableDataFrame(response.edges)]; +} + +// Generates node graph data but only returns the edges +export function generateRandomEdges(count = 10, seed = 1) { + return generateRandomNodes(count, seed)[1]; +} + +function makeDataFrames(): { + nodesFrame: DataFrame; + edgesFrame: DataFrame; + nodesFields: Record & { values: unknown[] }>; +} { + const nodesFields: Record & { values: unknown[] }> = { [NodeGraphDataFrameFieldNames.id]: { values: [], type: FieldType.string, @@ -114,12 +182,20 @@ export function generateRandomNodes(count = 10, seed?: number) { values: [], type: FieldType.boolean, }, + + [NodeGraphDataFrameFieldNames.detail + 'test_value']: { + values: [], + config: { + displayName: 'Test value', + }, + type: FieldType.number, + }, }; - const nodeFrame = new MutableDataFrame({ + const nodesFrame = new MutableDataFrame({ name: 'nodes', - fields: Object.keys(nodeFields).map((key) => ({ - ...nodeFields[key], + fields: Object.keys(nodesFields).map((key) => ({ + ...nodesFields[key], name: key, })), meta: { preferredVisualisationType: 'nodeGraph' }, @@ -134,67 +210,106 @@ export function generateRandomNodes(count = 10, seed?: number) { { name: NodeGraphDataFrameFieldNames.mainStat, values: [], type: FieldType.number, config: {} }, { name: NodeGraphDataFrameFieldNames.highlighted, values: [], type: FieldType.boolean, config: {} }, { name: NodeGraphDataFrameFieldNames.thickness, values: [], type: FieldType.number, config: {} }, + { name: NodeGraphDataFrameFieldNames.color, values: [], type: FieldType.string, config: {} }, + { name: NodeGraphDataFrameFieldNames.strokeDasharray, values: [], type: FieldType.string, config: {} }, ], meta: { preferredVisualisationType: 'nodeGraph' }, length: 0, }; - const edgesSet = new Set(); - for (const node of nodes) { - nodeFields.id.values.push(node.id); - nodeFields.title.values.push(node.title); - nodeFields[NodeGraphDataFrameFieldNames.subTitle].values.push(node.subTitle); - nodeFields[NodeGraphDataFrameFieldNames.mainStat].values.push(node.stat1); - nodeFields[NodeGraphDataFrameFieldNames.secondaryStat].values.push(node.stat2); - nodeFields.arc__success.values.push(node.success); - 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(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}`; - // We can have duplicate edges when we added some more by random - if (edgesSet.has(id)) { - continue; - } - edgesSet.add(id); - edgesFrame.fields[0].values.push(`${node.id}--${edge}`); - 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; - - return [nodeFrame, edgesFrame]; + return { nodesFrame, edgesFrame, nodesFields }; } -function makeRandomNode(index: number) { - const success = Math.random(); - const error = 1 - success; - return { - id: `service:${index}`, - title: `service:${index}`, - subTitle: 'service', - success, - error, - stat1: Math.random(), - stat2: Math.random(), - edges: [], - highlighted: Math.random() > 0.5, - }; -} +export function generateShowcaseData() { + const { nodesFrame, edgesFrame } = makeDataFrames(); -export function savedNodesResponse(size: 'small' | 'medium'): [DataFrame, DataFrame] { - const response = size === 'small' ? serviceMapResponseSmall : serviceMapResponsMedium; - return [new MutableDataFrame(response.nodes), new MutableDataFrame(response.edges)]; -} + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root', + [NodeGraphDataFrameFieldNames.title]: 'root', + [NodeGraphDataFrameFieldNames.subTitle]: 'client', + [NodeGraphDataFrameFieldNames.mainStat]: 1234, + [NodeGraphDataFrameFieldNames.secondaryStat]: 5678, + arc__success: 0.5, + arc__errors: 0.5, + [NodeGraphDataFrameFieldNames.icon]: '', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 1, + }); -// Generates node graph data but only returns the edges -export function generateRandomEdges(count = 10, seed = 1) { - return generateRandomNodes(count, seed)[1]; + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'app_service', + [NodeGraphDataFrameFieldNames.title]: 'app service', + [NodeGraphDataFrameFieldNames.subTitle]: 'with icon', + [NodeGraphDataFrameFieldNames.mainStat]: 1.2, + [NodeGraphDataFrameFieldNames.secondaryStat]: 2.3, + arc__success: 1, + arc__errors: 0, + [NodeGraphDataFrameFieldNames.icon]: 'apps', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 42, + }); + + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root-app_service', + [NodeGraphDataFrameFieldNames.source]: 'root', + [NodeGraphDataFrameFieldNames.target]: 'app_service', + [NodeGraphDataFrameFieldNames.mainStat]: 3.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.5, + [NodeGraphDataFrameFieldNames.thickness]: 4, + [NodeGraphDataFrameFieldNames.color]: '', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '', + }); + + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'auth_service', + [NodeGraphDataFrameFieldNames.title]: 'auth service', + [NodeGraphDataFrameFieldNames.subTitle]: 'highlighted', + [NodeGraphDataFrameFieldNames.mainStat]: 3.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.5, + arc__success: 0, + arc__errors: 1, + [NodeGraphDataFrameFieldNames.icon]: '', + [NodeGraphDataFrameFieldNames.nodeRadius]: 40, + [NodeGraphDataFrameFieldNames.highlighted]: true, + }); + + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'root-auth_service', + [NodeGraphDataFrameFieldNames.source]: 'root', + [NodeGraphDataFrameFieldNames.target]: 'auth_service', + [NodeGraphDataFrameFieldNames.mainStat]: 113.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 4.511, + [NodeGraphDataFrameFieldNames.thickness]: 8, + [NodeGraphDataFrameFieldNames.color]: 'red', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '', + }); + + addRow(nodesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'db', + [NodeGraphDataFrameFieldNames.title]: 'db', + [NodeGraphDataFrameFieldNames.subTitle]: 'bigger size', + [NodeGraphDataFrameFieldNames.mainStat]: 9876.123, + [NodeGraphDataFrameFieldNames.secondaryStat]: 123.9876, + arc__success: 0.9, + arc__errors: 0.1, + [NodeGraphDataFrameFieldNames.icon]: 'database', + [NodeGraphDataFrameFieldNames.nodeRadius]: 60, + [NodeGraphDataFrameFieldNames.highlighted]: false, + [NodeGraphDataFrameFieldNames.detail + 'test_value']: 1357, + }); + + addRow(edgesFrame, { + [NodeGraphDataFrameFieldNames.id]: 'auth_service-db', + [NodeGraphDataFrameFieldNames.source]: 'auth_service', + [NodeGraphDataFrameFieldNames.target]: 'db', + [NodeGraphDataFrameFieldNames.mainStat]: 1139.4, + [NodeGraphDataFrameFieldNames.secondaryStat]: 477.511, + [NodeGraphDataFrameFieldNames.thickness]: 2, + [NodeGraphDataFrameFieldNames.color]: 'blue', + [NodeGraphDataFrameFieldNames.strokeDasharray]: '2 2', + }); + + return [nodesFrame, edgesFrame]; } diff --git a/public/app/plugins/panel/nodeGraph/Edge.tsx b/public/app/plugins/panel/nodeGraph/Edge.tsx index b31c49be490..9a9459e9c19 100644 --- a/public/app/plugins/panel/nodeGraph/Edge.tsx +++ b/public/app/plugins/panel/nodeGraph/Edge.tsx @@ -5,7 +5,7 @@ import { computeNodeCircumferenceStrokeWidth, nodeR } from './Node'; import { EdgeDatum, NodeDatum } from './types'; import { shortenLine } from './utils'; -export const highlightedEdgeColor = '#a00'; +export const defaultHighlightedEdgeColor = '#a00'; export const defaultEdgeColor = '#999'; interface Props { @@ -41,12 +41,18 @@ export const Edge = memo(function Edge(props: Props) { arrowHeadHeight ); + const edgeColor = edge.color || defaultEdgeColor; + + // @deprecated -- until 'highlighted' is removed we'll prioritize 'color' + // in case both are provided + const highlightedEdgeColor = edge.color || defaultHighlightedEdgeColor; + const markerId = `triangle-${edge.id}`; const coloredMarkerId = `triangle-colored-${edge.id}`; return ( <> - + onClick(event, edge)} @@ -55,11 +61,12 @@ export const Edge = memo(function Edge(props: Props) { >