NodeGraph: Allow usage with single dataframe (#58448)

This commit is contained in:
Andrej Ocenas 2022-11-28 15:03:00 +01:00 committed by GitHub
parent f1dfaa784a
commit e033775264
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 518 additions and 388 deletions

View File

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

View File

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

View File

@ -23,7 +23,7 @@ export function NodeGraphEditor({ query, onChange }: Props) {
width={32}
/>
</InlineField>
{type === 'random' && (
{(type === 'random' || type === 'random edges') && (
<InlineField label="Count" labelWidth={14}>
<Input
type="number"
@ -41,4 +41,4 @@ export function NodeGraphEditor({ query, onChange }: Props) {
);
}
const options: Array<NodesQuery['type']> = ['random', 'response'];
const options: Array<NodesQuery['type']> = ['random', 'response', 'random edges'];

View File

@ -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<TestDataQuery> {
case 'response':
frames = savedNodesResponse();
break;
case 'random edges':
frames = [generateRandomEdges(target.nodes?.count)];
break;
default:
throw new Error(`Unknown node_graph sub type ${type}`);
}

View File

@ -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<string, Omit<FieldDTO, 'name'> & { 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];
}

View File

@ -30,7 +30,7 @@ export interface TestDataQuery extends DataQuery {
}
export interface NodesQuery {
type?: 'random' | 'response';
type?: 'random' | 'response' | 'random edges';
count?: number;
}

View File

@ -96,9 +96,14 @@ export const Node = memo(function Node(props: {
<g className={styles.text}>
<foreignObject x={node.x - (isHovered ? 100 : 35)} y={node.y - 15} width={isHovered ? '200' : '70'} height="40">
<div className={cx(styles.statsText, isHovered && styles.textHovering)}>
<span>{node.mainStat && statToString(node.mainStat, node.dataFrameRowIndex)}</span>
<span>
{node.mainStat && statToString(node.mainStat.config, node.mainStat.values.get(node.dataFrameRowIndex))}
</span>
<br />
<span>{node.secondaryStat && statToString(node.secondaryStat, node.dataFrameRowIndex)}</span>
<span>
{node.secondaryStat &&
statToString(node.secondaryStat.config, node.secondaryStat.values.get(node.dataFrameRowIndex))}
</span>
</div>
</foreignObject>
<foreignObject

View File

@ -27,7 +27,12 @@ describe('NodeGraph', () => {
});
it('can zoom in and out', async () => {
render(<NodeGraph dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]} getLinks={() => []} />);
render(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
getLinks={() => []}
/>
);
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(
<NodeGraph
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([[0, 1]])]}
dataFrames={[makeNodesDataFrame(2), makeEdgesDataFrame([{ source: '0', target: '1' }])]}
getLinks={(dataFrame) => {
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={() => []}

View File

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

View File

@ -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;
});
}

View File

@ -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<NodeDatum> & {
source: string;

View File

@ -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<SVGElement>, 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(
<ContextMenu
renderHeader={() => <NodeHeader node={node} nodes={nodes} />}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
setMenu(makeContextMenu(<NodeHeader node={node} nodes={nodes} />, renderer, event, setMenu));
}
},
[config, nodes, getLinks, setMenu, setConfig, setFocusedNodeId]
@ -66,18 +57,16 @@ export function useContextMenu(
const onEdgeOpen = useCallback(
(event: MouseEvent<SVGElement>, 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(
<ContextMenu
renderHeader={() => <EdgeHeader edge={edge} edges={edges} />}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
setMenu(makeContextMenu(<EdgeHeader edge={edge} edges={edges} />, 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<SVGElement>,
setMenu: (el: JSX.Element | undefined) => void
) {
return (
<ContextMenu
renderHeader={() => header}
renderMenuItems={renderer}
onClose={() => setMenu(undefined)}
x={event.pageX}
y={event.pageY}
/>
);
}
function getItemsRenderer<T extends NodeDatum | EdgeDatum>(
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 (
<div>
{fields.title && <Label field={fields.title} index={index} />}
{fields.subTitle && <Label field={fields.subTitle} index={index} />}
{fields.details.map((f) => (
<Label key={f.name} field={f} index={index} />
))}
</div>
);
function NodeHeader({ node, nodes }: { node: NodeDatum; nodes?: DataFrame }) {
const index = node.dataFrameRowIndex;
if (nodes) {
const fields = getNodeFields(nodes);
return (
<div>
{fields.title && (
<Label
label={fields.title.config.displayName || fields.title.name}
value={fields.title.values.get(index) || ''}
/>
)}
{fields.subTitle && (
<Label
label={fields.subTitle.config.displayName || fields.subTitle.name}
value={fields.subTitle.values.get(index) || ''}
/>
)}
{fields.details.map((f) => (
<Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
))}
</div>
);
} else {
// Fallback if we don't have nodes dataFrame. Can happen if we use just the edges frame to construct this.
return (
<div>
{node.title && <Label label={'Title'} value={node.title} />}
{node.subTitle && <Label label={'Subtitle'} value={node.subTitle} />}
</div>
);
}
}
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 }) {
</div>
)}
{fields.details.map((f) => (
<Label key={f.name} field={f} index={index} />
<Label key={f.name} label={f.config.displayName || f.name} value={f.values.get(index) || ''} />
))}
</div>
);
}
function Label(props: { field: Field; index: number }) {
const { field, index } = props;
const value = field.values.get(index) || '';
function Label({ label, value }: { label: string; value: string | number }) {
const styles = useStyles2(getLabelStyles);
return (
<div className={styles.label}>
<div>{field.config.displayName || field.name}</div>
<div>{label}</div>
<span className={styles.value}>{value}</span>
</div>
);

View File

@ -1,6 +1,6 @@
import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
import { ArrayVector, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
import { NodeGraphOptions } from './types';
import { NodeDatum, NodeGraphOptions } from './types';
import {
findConnectedNodesForEdge,
findConnectedNodesForNode,
@ -13,196 +13,27 @@ import {
} from './utils';
describe('processNodes', () => {
const theme = createTheme();
it('handles empty args', async () => {
expect(processNodes(undefined, undefined, theme)).toEqual({ nodes: [], edges: [] });
expect(processNodes(undefined, undefined)).toEqual({ nodes: [], edges: [] });
});
it('returns proper nodes and edges', async () => {
const { nodes, edges, legend } = processNodes(
makeNodesDataFrame(3),
makeEdgesDataFrame([
[0, 1],
[0, 2],
[1, 2],
]),
theme
{ source: '0', target: '1' },
{ source: '0', target: '2' },
{ source: '1', target: '2' },
])
);
const colorField = {
config: {
color: {
mode: 'continuous-GrYlRd',
},
},
index: 7,
name: 'color',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
};
expect(nodes).toEqual([
{
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:0',
},
{
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 1,
id: '1',
incoming: 1,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:1',
},
{
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 2,
id: '2',
incoming: 2,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:2',
},
makeNodeDatum(),
makeNodeDatum({ dataFrameRowIndex: 1, id: '1', incoming: 1, title: 'service:1' }),
makeNodeDatum({ dataFrameRowIndex: 2, id: '2', incoming: 2, title: 'service:2' }),
]);
expect(edges).toEqual([
{
dataFrameRowIndex: 0,
id: '0--1',
mainStat: '',
secondaryStat: '',
source: '0',
target: '1',
},
{
dataFrameRowIndex: 1,
id: '0--2',
mainStat: '',
secondaryStat: '',
source: '0',
target: '2',
},
{
dataFrameRowIndex: 2,
id: '1--2',
mainStat: '',
secondaryStat: '',
source: '1',
target: '2',
},
]);
expect(edges).toEqual([makeEdgeDatum('0--1', 0), makeEdgeDatum('0--2', 1), makeEdgeDatum('1--2', 2)]);
expect(legend).toEqual([
{
@ -216,6 +47,38 @@ describe('processNodes', () => {
]);
});
it('returns nodes just from edges dataframe', () => {
const { nodes, edges } = processNodes(
undefined,
makeEdgesDataFrame([
{ source: '0', target: '1', mainstat: 1, secondarystat: 1 },
{ source: '0', target: '2', mainstat: 1, secondarystat: 1 },
{ source: '1', target: '2', mainstat: 1, secondarystat: 1 },
])
);
expect(nodes).toEqual([
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 0, title: '0' })),
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 1, id: '1', incoming: 1, title: '1' })),
expect.objectContaining(makeNodeFromEdgeDatum({ dataFrameRowIndex: 2, id: '2', incoming: 2, title: '2' })),
]);
expect(nodes[0].mainStat?.values).toEqual(new ArrayVector([undefined, 1, 2]));
expect(nodes[0].secondaryStat?.values).toEqual(new ArrayVector([undefined, 1, 2]));
expect(nodes[0].mainStat).toEqual(nodes[1].mainStat);
expect(nodes[0].mainStat).toEqual(nodes[2].mainStat);
expect(nodes[0].secondaryStat).toEqual(nodes[1].secondaryStat);
expect(nodes[0].secondaryStat).toEqual(nodes[2].secondaryStat);
expect(edges).toEqual([
makeEdgeDatum('0--1', 0, '1.00', '1.00'),
makeEdgeDatum('0--2', 1, '1.00', '1.00'),
makeEdgeDatum('1--2', 2, '1.00', '1.00'),
]);
});
it('detects dataframes correctly', () => {
const validFrames = [
new MutableDataFrame({
@ -363,17 +226,14 @@ describe('processNodes', () => {
});
describe('finds connections', () => {
const theme = createTheme();
it('finds connected nodes given an edge id', () => {
const { nodes, edges } = processNodes(
makeNodesDataFrame(3),
makeEdgesDataFrame([
[0, 1],
[0, 2],
[1, 2],
]),
theme
{ source: '0', target: '1' },
{ source: '0', target: '2' },
{ source: '1', target: '2' },
])
);
const linked = findConnectedNodesForEdge(nodes, edges, edges[0].id);
@ -384,14 +244,96 @@ describe('finds connections', () => {
const { nodes, edges } = processNodes(
makeNodesDataFrame(4),
makeEdgesDataFrame([
[0, 1],
[0, 2],
[1, 2],
]),
theme
{ source: '0', target: '1' },
{ source: '0', target: '2' },
{ source: '1', target: '2' },
])
);
const linked = findConnectedNodesForNode(nodes, edges, nodes[0].id);
expect(linked).toEqual(['0', '1', '2']);
});
});
function makeNodeDatum(options: Partial<NodeDatum> = {}) {
const colorField = {
config: {
color: {
mode: 'continuous-GrYlRd',
},
},
index: 7,
name: 'color',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
};
return {
arcSections: [
{
config: {
color: {
fixedColor: 'green',
},
},
name: 'arc__success',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
{
config: {
color: {
fixedColor: 'red',
},
},
name: 'arc__errors',
type: 'number',
values: new ArrayVector([0.5, 0.5, 0.5]),
},
],
color: colorField,
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
mainStat: {
config: {},
index: 3,
name: 'mainstat',
type: 'number',
values: new ArrayVector([0.1, 0.1, 0.1]),
},
secondaryStat: {
config: {},
index: 4,
name: 'secondarystat',
type: 'number',
values: new ArrayVector([2, 2, 2]),
},
subTitle: 'service',
title: 'service:0',
...options,
};
}
function makeEdgeDatum(id: string, index: number, mainStat = '', secondaryStat = '') {
return {
dataFrameRowIndex: index,
id,
mainStat,
secondaryStat,
source: id.split('--')[0],
target: id.split('--')[1],
};
}
function makeNodeFromEdgeDatum(options: Partial<NodeDatum> = {}): NodeDatum {
return {
arcSections: [],
dataFrameRowIndex: 0,
id: '0',
incoming: 0,
subTitle: '',
title: 'service:0',
...options,
};
}

View File

@ -4,13 +4,13 @@ import {
Field,
FieldCache,
FieldColorModeId,
FieldConfig,
FieldType,
GrafanaTheme2,
MutableDataFrame,
NodeGraphDataFrameFieldNames,
} from '@grafana/data';
import { EdgeDatum, NodeDatum, NodeGraphOptions } from './types';
import { EdgeDatum, NodeDatum, NodeDatumFromEdge, NodeGraphOptions } from './types';
type Line = { x1: number; y1: number; x2: number; y2: number };
@ -36,7 +36,18 @@ export function shortenLine(line: Line, length: number): Line {
};
}
export function getNodeFields(nodes: DataFrame) {
export type NodeFields = {
id?: Field;
title?: Field;
subTitle?: Field;
mainStat?: Field;
secondaryStat?: Field;
arc: Field[];
details: Field[];
color?: Field;
};
export function getNodeFields(nodes: DataFrame): NodeFields {
const normalizedFrames = {
...nodes,
fields: nodes.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
@ -54,7 +65,16 @@ export function getNodeFields(nodes: DataFrame) {
};
}
export function getEdgeFields(edges: DataFrame) {
export type EdgeFields = {
id?: Field;
source?: Field;
target?: Field;
mainStat?: Field;
secondaryStat?: Field;
details: Field[];
};
export function getEdgeFields(edges: DataFrame): EdgeFields {
const normalizedFrames = {
...edges,
fields: edges.fields.map((field) => ({ ...field, name: field.name.toLowerCase() })),
@ -70,7 +90,7 @@ export function getEdgeFields(edges: DataFrame) {
};
}
function findFieldsByPrefix(frame: DataFrame, prefix: string) {
function findFieldsByPrefix(frame: DataFrame, prefix: string): Field[] {
return frame.fields.filter((f) => f.name.match(new RegExp('^' + prefix)));
}
@ -79,8 +99,7 @@ function findFieldsByPrefix(frame: DataFrame, prefix: string) {
*/
export function processNodes(
nodes: DataFrame | undefined,
edges: DataFrame | undefined,
theme: GrafanaTheme2
edges: DataFrame | undefined
): {
nodes: NodeDatum[];
edges: EdgeDatum[];
@ -89,76 +108,206 @@ export function processNodes(
name: string;
}>;
} {
if (!nodes) {
if (!(edges || nodes)) {
return { nodes: [], edges: [] };
}
const nodeFields = getNodeFields(nodes);
if (!nodeFields.id) {
throw new Error('id field is required for nodes data frame.');
}
const nodesMap =
nodeFields.id.values.toArray().reduce<{ [id: string]: NodeDatum }>((acc, id, index) => {
acc[id] = {
id: id,
title: nodeFields.title?.values.get(index) || '',
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
dataFrameRowIndex: index,
incoming: 0,
mainStat: nodeFields.mainStat,
secondaryStat: nodeFields.secondaryStat,
arcSections: nodeFields.arc,
color: nodeFields.color,
};
return acc;
}, {}) || {};
let edgesMapped: EdgeDatum[] = [];
// We may not have edges in case of single node
if (edges) {
const edgeFields = getEdgeFields(edges);
if (!edgeFields.id) {
throw new Error('id field is required for edges data frame.');
if (nodes) {
const nodeFields = getNodeFields(nodes);
if (!nodeFields.id) {
throw new Error('id field is required for nodes data frame.');
}
edgesMapped = edgeFields.id.values.toArray().map((id, index) => {
const target = edgeFields.target?.values.get(index);
const source = edgeFields.source?.values.get(index);
// We are adding incoming edges count so we can later on find out which nodes are the roots
nodesMap[target].incoming++;
// Create the nodes here
const nodesMap: { [id: string]: NodeDatum } = {};
for (let i = 0; i < nodeFields.id.values.length; i++) {
const id = nodeFields.id.values.get(i);
nodesMap[id] = makeNodeDatum(id, nodeFields, i);
}
return {
id,
dataFrameRowIndex: index,
source,
target,
mainStat: edgeFields.mainStat ? statToString(edgeFields.mainStat, index) : '',
secondaryStat: edgeFields.secondaryStat ? statToString(edgeFields.secondaryStat, index) : '',
} as EdgeDatum;
});
// We may not have edges in case of single node
let edgeDatums: EdgeDatum[] = edges ? processEdges(edges, getEdgeFields(edges)) : [];
for (const e of edgeDatums) {
// We are adding incoming edges count, so we can later on find out which nodes are the roots
nodesMap[e.target].incoming++;
}
return {
nodes: Object.values(nodesMap),
edges: edgeDatums,
legend: nodeFields.arc.map((f) => {
return {
color: f.config.color?.fixedColor ?? '',
name: f.config.displayName || f.name,
};
}),
};
} else {
// We have only edges here, so we have to construct also nodes out of them
// We checked that either node || edges has to be defined and if nodes aren't edges has to be defined
edges = edges!;
const nodesMap: { [id: string]: NodeDatumFromEdge } = {};
const edgeFields = getEdgeFields(edges);
let edgeDatums = processEdges(edges, edgeFields);
// Turn edges into reasonable filled in nodes
for (let i = 0; i < edgeDatums.length; i++) {
const edge = edgeDatums[i];
const { source, target } = makeNodeDatumsFromEdge(edgeFields, i);
nodesMap[target.id] = nodesMap[target.id] || target;
nodesMap[source.id] = nodesMap[source.id] || source;
// Check the stats fields. They can be also strings which we cannot really aggregate so only aggregate in case
// they are numbers. Here we just sum all incoming edges to get the final value for node.
if (computableField(edgeFields.mainStat)) {
nodesMap[target.id].mainStatNumeric =
(nodesMap[target.id].mainStatNumeric ?? 0) + edgeFields.mainStat!.values.get(i);
}
if (computableField(edgeFields.secondaryStat)) {
nodesMap[target.id].secondaryStatNumeric =
(nodesMap[target.id].secondaryStatNumeric ?? 0) + edgeFields.secondaryStat!.values.get(i);
}
// We are adding incoming edges count, so we can later on find out which nodes are the roots
nodesMap[edge.target].incoming++;
}
// It is expected for stats to be Field, so we have to create them.
const nodes = normalizeStatsForNodes(nodesMap, edgeFields);
return {
nodes,
edges: edgeDatums,
};
}
}
/**
* Turn data frame data into EdgeDatum that node graph understands
* @param edges
* @param edgeFields
*/
function processEdges(edges: DataFrame, edgeFields: EdgeFields): EdgeDatum[] {
if (!edgeFields.id) {
throw new Error('id field is required for edges data frame.');
}
return {
nodes: Object.values(nodesMap),
edges: edgesMapped || [],
legend: nodeFields.arc.map((f) => {
return {
color: f.config.color?.fixedColor ?? '',
name: f.config.displayName || f.name,
return edgeFields.id.values.toArray().map((id, index) => {
const target = edgeFields.target?.values.get(index);
const source = edgeFields.source?.values.get(index);
return {
id,
dataFrameRowIndex: index,
source,
target,
mainStat: edgeFields.mainStat
? statToString(edgeFields.mainStat.config, edgeFields.mainStat.values.get(index))
: '',
secondaryStat: edgeFields.secondaryStat
? statToString(edgeFields.secondaryStat.config, edgeFields.secondaryStat.values.get(index))
: '',
} as EdgeDatum;
});
}
function computableField(field?: Field) {
return field && field.type === FieldType.number;
}
/**
* Instead of just simple numbers node graph requires to have Field in NodeDatum (probably for some formatting info in
* config). So we create them here and fill with correct data.
* @param nodesMap
* @param edgeFields
*/
function normalizeStatsForNodes(nodesMap: { [id: string]: NodeDatumFromEdge }, edgeFields: EdgeFields): NodeDatum[] {
const secondaryStatValues = new ArrayVector();
const mainStatValues = new ArrayVector();
const secondaryStatField = computableField(edgeFields.secondaryStat)
? {
...edgeFields.secondaryStat!,
values: secondaryStatValues,
}
: undefined;
const mainStatField = computableField(edgeFields.mainStat)
? {
...edgeFields.mainStat!,
values: mainStatValues,
}
: undefined;
return Object.values(nodesMap).map((node, index) => {
if (mainStatField || secondaryStatField) {
const newNode = {
...node,
};
}),
if (mainStatField) {
newNode.mainStat = mainStatField;
mainStatValues.add(node.mainStatNumeric);
newNode.dataFrameRowIndex = index;
}
if (secondaryStatField) {
newNode.secondaryStat = secondaryStatField;
secondaryStatValues.add(node.secondaryStatNumeric);
newNode.dataFrameRowIndex = index;
}
return newNode;
}
return node;
});
}
function makeNodeDatumsFromEdge(edgeFields: EdgeFields, index: number) {
const targetId = edgeFields.target?.values.get(index);
const sourceId = edgeFields.source?.values.get(index);
return {
target: makeSimpleNodeDatum(targetId, index),
source: makeSimpleNodeDatum(sourceId, index),
};
}
export function statToString(field: Field, index: number) {
if (field.type === FieldType.string) {
return field.values.get(index);
function makeSimpleNodeDatum(name: string, index: number): NodeDatumFromEdge {
return {
id: name,
title: name,
subTitle: '',
dataFrameRowIndex: index,
incoming: 0,
arcSections: [],
};
}
function makeNodeDatum(id: string, nodeFields: NodeFields, index: number) {
return {
id: id,
title: nodeFields.title?.values.get(index) || '',
subTitle: nodeFields.subTitle ? nodeFields.subTitle.values.get(index) : '',
dataFrameRowIndex: index,
incoming: 0,
mainStat: nodeFields.mainStat,
secondaryStat: nodeFields.secondaryStat,
arcSections: nodeFields.arc,
color: nodeFields.color,
};
}
export function statToString(config: FieldConfig, value: number | string): string {
if (typeof value === 'string') {
return value;
} else {
const decimals = field.config.decimals || 2;
const val = field.values.get(index);
if (Number.isFinite(val)) {
return field.values.get(index).toFixed(decimals) + (field.config.unit ? ' ' + field.config.unit : '');
const decimals = config.decimals || 2;
if (Number.isFinite(value)) {
return value.toFixed(decimals) + (config.unit ? ' ' + config.unit : '');
} else {
return '';
}
@ -240,13 +389,14 @@ function nodesFrame() {
});
}
export function makeEdgesDataFrame(edges: Array<[number, number]>) {
export function makeEdgesDataFrame(
edges: Array<Partial<{ source: string; target: string; mainstat: number; secondarystat: number }>>
) {
const frame = edgesFrame();
for (const edge of edges) {
frame.add({
id: edge[0] + '--' + edge[1],
source: edge[0].toString(),
target: edge[1].toString(),
id: edge.source + '--' + edge.target,
...edge,
});
}
@ -267,6 +417,14 @@ function edgesFrame() {
values: new ArrayVector(),
type: FieldType.string,
},
[NodeGraphDataFrameFieldNames.mainStat]: {
values: new ArrayVector(),
type: FieldType.number,
},
[NodeGraphDataFrameFieldNames.secondaryStat]: {
values: new ArrayVector(),
type: FieldType.number,
},
};
return new MutableDataFrame({