Node Graph Panel: Add options to configure units and arc colors (#51057)

* Node Graph Panel: Add options to configure units and arc colors

* Add tests
pull/51335/head
Connor Lindsey 3 years ago committed by GitHub
parent d20afa2a39
commit 16aaffe0a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 11
      public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx
  2. 76
      public/app/plugins/panel/nodeGraph/editor/ArcOptionsEditor.tsx
  3. 40
      public/app/plugins/panel/nodeGraph/module.tsx
  4. 21
      public/app/plugins/panel/nodeGraph/types.ts
  5. 65
      public/app/plugins/panel/nodeGraph/utils.test.ts
  6. 61
      public/app/plugins/panel/nodeGraph/utils.ts

@ -6,10 +6,15 @@ import { PanelProps } from '@grafana/data';
import { useLinks } from '../../../features/explore/utils/links';
import { NodeGraph } from './NodeGraph';
import { Options } from './types';
import { NodeGraphOptions } from './types';
import { getNodeGraphDataFrames } from './utils';
export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ width, height, data }) => {
export const NodeGraphPanel: React.FunctionComponent<PanelProps<NodeGraphOptions>> = ({
width,
height,
data,
options,
}) => {
const getLinks = useLinks(data.timeRange);
if (!data || !data.series.length) {
return (
@ -22,7 +27,7 @@ export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ w
const memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
return (
<div style={{ width, height }}>
<NodeGraph dataFrames={memoizedGetNodeGraphDataFrames(data.series)} getLinks={getLinks} />
<NodeGraph dataFrames={memoizedGetNodeGraphDataFrames(data.series, options)} getLinks={getLinks} />
</div>
);
};

@ -0,0 +1,76 @@
import { css } from '@emotion/css';
import React from 'react';
import { Field, FieldNamePickerConfigSettings, StandardEditorProps, StandardEditorsRegistryItem } from '@grafana/data';
import { Button, ColorPicker, useStyles2 } from '@grafana/ui';
import { FieldNamePicker } from '@grafana/ui/src/components/MatchersUI/FieldNamePicker';
import { ArcOption, NodeGraphOptions } from '../types';
type ArcOptionsEditorProps = StandardEditorProps<ArcOption[], any, NodeGraphOptions, any>;
const fieldNamePickerSettings: StandardEditorsRegistryItem<string, FieldNamePickerConfigSettings> = {
settings: { filter: (field: Field) => field.name.includes('arc__') },
} as any;
export const ArcOptionsEditor = ({ value, onChange, context }: ArcOptionsEditorProps) => {
const styles = useStyles2(getStyles);
const addArc = () => {
const newArc = { field: '', color: '' };
onChange(value ? [...value, newArc] : [newArc]);
};
const removeArc = (idx: number) => {
const copy = value?.slice();
copy.splice(idx, 1);
onChange(copy);
};
const updateField = <K extends keyof ArcOption>(idx: number, field: K, newValue: ArcOption[K]) => {
let arcs = value?.slice() ?? [];
arcs[idx][field] = newValue;
onChange(arcs);
};
return (
<>
{value?.map((arc, i) => {
return (
<div className={styles.section} key={i}>
<FieldNamePicker
context={context}
value={arc.field ?? ''}
onChange={(val) => {
updateField(i, 'field', val);
}}
item={fieldNamePickerSettings}
/>
<ColorPicker
color={arc.color || '#808080'}
onChange={(val) => {
updateField(i, 'color', val);
}}
/>
<Button size="sm" icon="minus" variant="secondary" onClick={() => removeArc(i)} title="Remove arc" />
</div>
);
})}
<Button size={'sm'} icon="plus" onClick={addArc} variant="secondary">
Add arc
</Button>
</>
);
};
const getStyles = () => {
return {
section: css`
display: flex;
align-items: center;
justify-content: space-between;
gap: 0 8px;
margin-bottom: 8px;
`,
};
};

@ -1,6 +1,42 @@
import { PanelPlugin } from '@grafana/data';
import { NodeGraphPanel } from './NodeGraphPanel';
import { Options } from './types';
import { ArcOptionsEditor } from './editor/ArcOptionsEditor';
import { NodeGraphOptions } from './types';
export const plugin = new PanelPlugin<Options>(NodeGraphPanel);
export const plugin = new PanelPlugin<NodeGraphOptions>(NodeGraphPanel).setPanelOptions((builder, context) => {
builder.addNestedOptions({
category: ['Nodes'],
path: 'nodes',
build: (builder) => {
builder.addUnitPicker({
name: 'Main stat unit',
path: 'mainStatUnit',
});
builder.addUnitPicker({
name: 'Secondary stat unit',
path: 'secondaryStatUnit',
});
builder.addCustomEditor({
name: 'Arc sections',
path: 'arcs',
id: 'arcs',
editor: ArcOptionsEditor,
});
},
});
builder.addNestedOptions({
category: ['Edges'],
path: 'edges',
build: (builder) => {
builder.addUnitPicker({
name: 'Main stat unit',
path: 'mainStatUnit',
});
builder.addUnitPicker({
name: 'Secondary stat unit',
path: 'secondaryStatUnit',
});
},
});
});

@ -2,7 +2,26 @@ import { SimulationNodeDatum, SimulationLinkDatum } from 'd3-force';
import { Field } from '@grafana/data';
export interface Options {}
export interface NodeGraphOptions {
nodes?: NodeOptions;
edges?: EdgeOptions;
}
interface NodeOptions {
mainStatUnit?: string;
secondaryStatUnit?: string;
arcs?: ArcOption[];
}
export interface ArcOption {
field?: string;
color?: any;
}
interface EdgeOptions {
mainStatUnit?: string;
secondaryStatUnit?: string;
}
export type NodeDatum = SimulationNodeDatum & {
id: string;

@ -1,5 +1,6 @@
import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
import { NodeGraphOptions } from './types';
import {
getEdgeFields,
getNodeFields,
@ -293,4 +294,68 @@ describe('processNodes', () => {
expect(edgeFields.mainStat).toBeDefined();
expect(edgeFields.secondaryStat).toBeDefined();
});
it('interpolates panel options correctly', () => {
const frames = [
new MutableDataFrame({
refId: 'nodes',
fields: [
{ name: 'id', type: FieldType.string },
{ name: 'mainStat', type: FieldType.string },
{ name: 'secondaryStat', type: FieldType.string },
{ name: 'arc__primary', type: FieldType.string },
{ name: 'arc__secondary', type: FieldType.string },
{ name: 'arc__tertiary', type: FieldType.string },
],
}),
new MutableDataFrame({
refId: 'edges',
fields: [
{ name: 'id', type: FieldType.string },
{ name: 'source', type: FieldType.string },
{ name: 'target', type: FieldType.string },
{ name: 'mainStat', type: FieldType.string },
{ name: 'secondaryStat', type: FieldType.string },
],
}),
];
const panelOptions: NodeGraphOptions = {
nodes: {
mainStatUnit: 'r/min',
secondaryStatUnit: 'ms/r',
arcs: [
{ field: 'arc__primary', color: 'red' },
{ field: 'arc__secondary', color: 'yellow' },
{ field: 'arc__tertiary', color: '#dd40ec' },
],
},
edges: {
mainStatUnit: 'r/sec',
secondaryStatUnit: 'ft^2',
},
};
const nodeGraphFrames = getNodeGraphDataFrames(frames, panelOptions);
expect(nodeGraphFrames).toHaveLength(2);
const nodesFrame = nodeGraphFrames.find((f) => f.refId === 'nodes');
expect(nodesFrame).toBeDefined();
expect(nodesFrame?.fields.find((f) => f.name === 'mainStat')?.config).toEqual({ unit: 'r/min' });
expect(nodesFrame?.fields.find((f) => f.name === 'secondaryStat')?.config).toEqual({ unit: 'ms/r' });
expect(nodesFrame?.fields.find((f) => f.name === 'arc__primary')?.config).toEqual({
color: { mode: 'fixed', fixedColor: 'red' },
});
expect(nodesFrame?.fields.find((f) => f.name === 'arc__secondary')?.config).toEqual({
color: { mode: 'fixed', fixedColor: 'yellow' },
});
expect(nodesFrame?.fields.find((f) => f.name === 'arc__tertiary')?.config).toEqual({
color: { mode: 'fixed', fixedColor: '#dd40ec' },
});
const edgesFrame = nodeGraphFrames.find((f) => f.refId === 'edges');
expect(edgesFrame).toBeDefined();
expect(edgesFrame?.fields.find((f) => f.name === 'mainStat')?.config).toEqual({ unit: 'r/sec' });
expect(edgesFrame?.fields.find((f) => f.name === 'secondaryStat')?.config).toEqual({ unit: 'ft^2' });
});
});

@ -3,13 +3,14 @@ import {
DataFrame,
Field,
FieldCache,
FieldColorModeId,
FieldType,
GrafanaTheme2,
MutableDataFrame,
NodeGraphDataFrameFieldNames,
} from '@grafana/data';
import { EdgeDatum, NodeDatum } from './types';
import { EdgeDatum, NodeDatum, NodeGraphOptions } from './types';
type Line = { x1: number; y1: number; x2: number; y2: number };
@ -327,12 +328,12 @@ export function graphBounds(nodes: NodeDatum[]): Bounds {
};
}
export function getNodeGraphDataFrames(frames: DataFrame[]) {
export function getNodeGraphDataFrames(frames: DataFrame[], options?: NodeGraphOptions) {
// TODO: this not in sync with how other types of responses are handled. Other types have a query response
// processing pipeline which ends up populating redux state with proper data. As we move towards more dataFrame
// oriented API it seems like a better direction to move such processing into to visualisations and do minimal
// and lazy processing here. Needs bigger refactor so keeping nodeGraph and Traces as they are for now.
return frames.filter((frame) => {
let nodeGraphFrames = frames.filter((frame) => {
if (frame.meta?.preferredVisualisationType === 'nodeGraph') {
return true;
}
@ -348,4 +349,58 @@ export function getNodeGraphDataFrames(frames: DataFrame[]) {
return false;
});
// If panel options are provided, interpolate their values in to the data frames
if (options) {
nodeGraphFrames = applyOptionsToFrames(nodeGraphFrames, options);
}
return nodeGraphFrames;
}
export const applyOptionsToFrames = (frames: DataFrame[], options: NodeGraphOptions): DataFrame[] => {
return frames.map((frame) => {
const fieldsCache = new FieldCache(frame);
// Edges frame has source which can be used to identify nodes vs edges frames
if (fieldsCache.getFieldByName(NodeGraphDataFrameFieldNames.source.toLowerCase())) {
if (options?.edges?.mainStatUnit) {
const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat);
if (field) {
field.config = { ...field.config, unit: options.edges.mainStatUnit };
}
}
if (options?.edges?.secondaryStatUnit) {
const field = frame.fields.find(
(field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat
);
if (field) {
field.config = { ...field.config, unit: options.edges.secondaryStatUnit };
}
}
} else {
if (options?.nodes?.mainStatUnit) {
const field = frame.fields.find((field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.mainStat);
if (field) {
field.config = { ...field.config, unit: options.nodes.mainStatUnit };
}
}
if (options?.nodes?.secondaryStatUnit) {
const field = frame.fields.find(
(field) => field.name.toLowerCase() === NodeGraphDataFrameFieldNames.secondaryStat
);
if (field) {
field.config = { ...field.config, unit: options.nodes.secondaryStatUnit };
}
}
if (options?.nodes?.arcs?.length) {
for (const arc of options.nodes.arcs) {
const field = frame.fields.find((field) => field.name.toLowerCase() === arc.field);
if (field && arc.color) {
field.config = { ...field.config, color: { fixedColor: arc.color, mode: FieldColorModeId.Fixed } };
}
}
}
}
return frame;
});
};

Loading…
Cancel
Save