mirror of
https://github.com/grafana/grafana.git
synced 2025-09-25 22:43:57 +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 { useLinks } from '../../../features/explore/utils/links';
|
||||||
|
|
||||||
import { NodeGraph } from './NodeGraph';
|
import { NodeGraph } from './NodeGraph';
|
||||||
import { Options } from './types';
|
import { NodeGraphOptions } from './types';
|
||||||
import { getNodeGraphDataFrames } from './utils';
|
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);
|
const getLinks = useLinks(data.timeRange);
|
||||||
if (!data || !data.series.length) {
|
if (!data || !data.series.length) {
|
||||||
return (
|
return (
|
||||||
@ -22,7 +27,7 @@ export const NodeGraphPanel: React.FunctionComponent<PanelProps<Options>> = ({ w
|
|||||||
const memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
|
const memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
|
||||||
return (
|
return (
|
||||||
<div style={{ width, height }}>
|
<div style={{ width, height }}>
|
||||||
<NodeGraph dataFrames={memoizedGetNodeGraphDataFrames(data.series)} getLinks={getLinks} />
|
<NodeGraph dataFrames={memoizedGetNodeGraphDataFrames(data.series, options)} getLinks={getLinks} />
|
||||||
</div>
|
</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 { PanelPlugin } from '@grafana/data';
|
||||||
|
|
||||||
import { NodeGraphPanel } from './NodeGraphPanel';
|
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';
|
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 & {
|
export type NodeDatum = SimulationNodeDatum & {
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
|
import { ArrayVector, createTheme, DataFrame, FieldType, MutableDataFrame } from '@grafana/data';
|
||||||
|
|
||||||
|
import { NodeGraphOptions } from './types';
|
||||||
import {
|
import {
|
||||||
getEdgeFields,
|
getEdgeFields,
|
||||||
getNodeFields,
|
getNodeFields,
|
||||||
@ -293,4 +294,68 @@ describe('processNodes', () => {
|
|||||||
expect(edgeFields.mainStat).toBeDefined();
|
expect(edgeFields.mainStat).toBeDefined();
|
||||||
expect(edgeFields.secondaryStat).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,
|
DataFrame,
|
||||||
Field,
|
Field,
|
||||||
FieldCache,
|
FieldCache,
|
||||||
|
FieldColorModeId,
|
||||||
FieldType,
|
FieldType,
|
||||||
GrafanaTheme2,
|
GrafanaTheme2,
|
||||||
MutableDataFrame,
|
MutableDataFrame,
|
||||||
NodeGraphDataFrameFieldNames,
|
NodeGraphDataFrameFieldNames,
|
||||||
} from '@grafana/data';
|
} from '@grafana/data';
|
||||||
|
|
||||||
import { EdgeDatum, NodeDatum } from './types';
|
import { EdgeDatum, NodeDatum, NodeGraphOptions } from './types';
|
||||||
|
|
||||||
type Line = { x1: number; y1: number; x2: number; y2: number };
|
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
|
// 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
|
// 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
|
// 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.
|
// 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') {
|
if (frame.meta?.preferredVisualisationType === 'nodeGraph') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -348,4 +349,58 @@ export function getNodeGraphDataFrames(frames: DataFrame[]) {
|
|||||||
|
|
||||||
return false;
|
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