NodeGraph: Edge color and stroke-dasharray support (#83855)

* Adds color and stroke-dasharray support for node graph edges

Adds support for providing color, highlighted color, and visual display of node graph edges as dashed lines via stroke-dasharray.

* Updates node graph documentation

* Updates documentation

Adds default for `highlightedColor`

* Update docs/sources/panels-visualizations/visualizations/node-graph/index.md

Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com>

* Update packages/grafana-data/src/utils/nodeGraph.ts

Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com>

* Update docs/sources/panels-visualizations/visualizations/node-graph/index.md

Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>

* Removes highlightedColor; deprecates highlighted

Per [request](https://github.com/grafana/grafana/pull/83855#issuecomment-1999810826), deprecates `highlighted` in code and documentation, and removes `highlightedColor` as an additional property. `highlighted` will continue to be supported in its original state (makes the edge red), but is superseded if `color` is provided.

* Update types.ts

Missed a file in my last commit. Removes `highlightedColor` and deprecates `highlighted`.

* Add test scenario in test data source

---------

Co-authored-by: Fabrizio <135109076+fabrizio-grafana@users.noreply.github.com>
Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com>
Co-authored-by: Andrej Ocenas <mr.ocenas@gmail.com>
This commit is contained in:
Rob
2024-03-18 10:26:22 -05:00
committed by GitHub
parent e96836d19e
commit 677b765dab
13 changed files with 239 additions and 82 deletions

View File

@@ -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"]
],

View File

@@ -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

View File

@@ -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',
}

View File

@@ -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 {

View File

@@ -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.

View File

@@ -54,4 +54,10 @@ export function NodeGraphEditor({ query, onChange }: Props) {
);
}
const options: Array<NodesQuery['type']> = ['random', 'response_small', 'response_medium', 'random edges'];
const options: Array<NodesQuery['type']> = [
'random',
'response_small',
'response_medium',
'random edges',
'feature_showcase',
];

View File

@@ -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")

View File

@@ -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 {

View File

@@ -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<TestDataDataQuery>
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;

View File

@@ -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<string, Omit<FieldDTO, 'name'> & { 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<string, Omit<FieldDTO, 'name'> & { values: unknown[] }>;
} {
const nodesFields: Record<string, Omit<FieldDTO, 'name'> & { 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];
}

View File

@@ -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 (
<>
<EdgeArrowMarker id={markerId} headHeight={arrowHeadHeight} />
<EdgeArrowMarker id={markerId} fill={edgeColor} headHeight={arrowHeadHeight} />
<EdgeArrowMarker id={coloredMarkerId} fill={highlightedEdgeColor} headHeight={arrowHeadHeight} />
<g
onClick={(event) => onClick(event, edge)}
@@ -55,11 +61,12 @@ export const Edge = memo(function Edge(props: Props) {
>
<line
strokeWidth={(hovering ? 1 : 0) + (edge.highlighted ? 1 : 0) + edge.thickness}
stroke={edge.highlighted ? highlightedEdgeColor : defaultEdgeColor}
stroke={edge.highlighted ? highlightedEdgeColor : edgeColor}
x1={line.x1}
y1={line.y1}
x2={line.x2}
y2={line.y2}
strokeDasharray={edge.strokeDasharray}
markerEnd={`url(#${edge.highlighted ? coloredMarkerId : markerId})`}
/>
<line

View File

@@ -35,8 +35,13 @@ export type EdgeDatum = LinkDatum & {
dataFrameRowIndex: number;
sourceNodeRadius: number;
targetNodeRadius: number;
/**
* @deprecated -- for edges use color instead
*/
highlighted: boolean;
thickness: number;
color?: string;
strokeDasharray?: string;
};
// After layout is run D3 will change the string IDs for actual references to the nodes.

View File

@@ -86,8 +86,13 @@ export type EdgeFields = {
mainStat?: Field;
secondaryStat?: Field;
details: Field[];
/**
* @deprecated use `color` instead
*/
highlighted?: Field;
thickness?: Field;
color?: Field;
strokeDasharray?: Field;
};
export function getEdgeFields(edges: DataFrame): EdgeFields {
@@ -103,8 +108,11 @@ export function getEdgeFields(edges: DataFrame): EdgeFields {
mainStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.mainStat.toLowerCase()),
secondaryStat: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.secondaryStat.toLowerCase()),
details: findFieldsByPrefix(edges, NodeGraphDataFrameFieldNames.detail.toLowerCase()),
// @deprecated -- for edges use color instead
highlighted: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.highlighted.toLowerCase()),
thickness: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.thickness.toLowerCase()),
color: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.color.toLowerCase()),
strokeDasharray: fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.strokeDasharray.toLowerCase()),
};
}
@@ -234,8 +242,11 @@ function processEdges(edges: DataFrame, edgeFields: EdgeFields, nodesMap: { [id:
secondaryStat: edgeFields.secondaryStat
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values[index])
: '',
// @deprecated -- for edges use color instead
highlighted: edgeFields.highlighted?.values[index] || false,
thickness: edgeFields.thickness?.values[index] || 1,
color: edgeFields.color?.values[index],
strokeDasharray: edgeFields.strokeDasharray?.values[index],
};
});
}