diff --git a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx index d98d0597789..19425c96a6f 100644 --- a/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx +++ b/public/app/plugins/panel/nodeGraph/NodeGraphPanel.tsx @@ -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> = ({ width, height, data }) => { +export const NodeGraphPanel: React.FunctionComponent> = ({ + width, + height, + data, + options, +}) => { const getLinks = useLinks(data.timeRange); if (!data || !data.series.length) { return ( @@ -22,7 +27,7 @@ export const NodeGraphPanel: React.FunctionComponent> = ({ w const memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames); return (
- +
); }; diff --git a/public/app/plugins/panel/nodeGraph/editor/ArcOptionsEditor.tsx b/public/app/plugins/panel/nodeGraph/editor/ArcOptionsEditor.tsx new file mode 100644 index 00000000000..eabd4d5d980 --- /dev/null +++ b/public/app/plugins/panel/nodeGraph/editor/ArcOptionsEditor.tsx @@ -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; + +const fieldNamePickerSettings: StandardEditorsRegistryItem = { + 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 = (idx: number, field: K, newValue: ArcOption[K]) => { + let arcs = value?.slice() ?? []; + arcs[idx][field] = newValue; + onChange(arcs); + }; + + return ( + <> + {value?.map((arc, i) => { + return ( +
+ { + updateField(i, 'field', val); + }} + item={fieldNamePickerSettings} + /> + { + updateField(i, 'color', val); + }} + /> +
+ ); + })} + + + ); +}; + +const getStyles = () => { + return { + section: css` + display: flex; + align-items: center; + justify-content: space-between; + gap: 0 8px; + margin-bottom: 8px; + `, + }; +}; diff --git a/public/app/plugins/panel/nodeGraph/module.tsx b/public/app/plugins/panel/nodeGraph/module.tsx index 15416636639..5d444e17e3c 100644 --- a/public/app/plugins/panel/nodeGraph/module.tsx +++ b/public/app/plugins/panel/nodeGraph/module.tsx @@ -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(NodeGraphPanel); +export const plugin = new PanelPlugin(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', + }); + }, + }); +}); diff --git a/public/app/plugins/panel/nodeGraph/types.ts b/public/app/plugins/panel/nodeGraph/types.ts index 1c15b9e5688..a7758a1e43c 100644 --- a/public/app/plugins/panel/nodeGraph/types.ts +++ b/public/app/plugins/panel/nodeGraph/types.ts @@ -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; diff --git a/public/app/plugins/panel/nodeGraph/utils.test.ts b/public/app/plugins/panel/nodeGraph/utils.test.ts index 9ca99d86ac5..8e382a38829 100644 --- a/public/app/plugins/panel/nodeGraph/utils.test.ts +++ b/public/app/plugins/panel/nodeGraph/utils.test.ts @@ -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' }); + }); }); diff --git a/public/app/plugins/panel/nodeGraph/utils.ts b/public/app/plugins/panel/nodeGraph/utils.ts index 2de6eb997ef..593c6263450 100644 --- a/public/app/plugins/panel/nodeGraph/utils.ts +++ b/public/app/plugins/panel/nodeGraph/utils.ts @@ -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; + }); +};