mirror of
https://github.com/grafana/grafana.git
synced 2025-09-26 05:44:18 +08:00
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
This commit is contained in:
@ -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;
|
||||
});
|
||||
};
|
||||
|
Reference in New Issue
Block a user