From 5fd7c34420aff7e9f365f7af2d7f4b527c12c2c7 Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Mon, 10 May 2021 14:00:59 -0500 Subject: [PATCH] New Panel: Histogram (#33752) --- .../grafana-data/src/transformations/index.ts | 1 + .../src/transformations/transformers.ts | 2 + .../transformers/histogram.test.ts | 172 ++++++++++ .../transformations/transformers/histogram.ts | 306 ++++++++++++++++++ .../src/transformations/transformers/ids.ts | 1 + .../transformers/joinDataFrames.ts | 4 +- .../uPlot/config/UPlotAxisBuilder.ts | 1 + .../HistogramTransformerEditor.tsx | 91 ++++++ public/app/core/utils/standardTransformers.ts | 2 + .../app/features/plugins/built_in_plugins.ts | 2 + .../app/plugins/panel/histogram/Histogram.tsx | 257 +++++++++++++++ .../panel/histogram/HistogramPanel.tsx | 51 +++ .../plugins/panel/histogram/img/histogram.svg | 1 + public/app/plugins/panel/histogram/models.cue | 18 ++ .../app/plugins/panel/histogram/models.gen.ts | 36 +++ public/app/plugins/panel/histogram/module.tsx | 92 ++++++ .../app/plugins/panel/histogram/plugin.json | 18 ++ public/app/plugins/panel/histogram/utils.ts | 30 ++ 18 files changed, 1083 insertions(+), 2 deletions(-) create mode 100644 packages/grafana-data/src/transformations/transformers/histogram.test.ts create mode 100644 packages/grafana-data/src/transformations/transformers/histogram.ts create mode 100644 public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx create mode 100644 public/app/plugins/panel/histogram/Histogram.tsx create mode 100644 public/app/plugins/panel/histogram/HistogramPanel.tsx create mode 100644 public/app/plugins/panel/histogram/img/histogram.svg create mode 100644 public/app/plugins/panel/histogram/models.cue create mode 100644 public/app/plugins/panel/histogram/models.gen.ts create mode 100644 public/app/plugins/panel/histogram/module.tsx create mode 100644 public/app/plugins/panel/histogram/plugin.json create mode 100644 public/app/plugins/panel/histogram/utils.ts diff --git a/packages/grafana-data/src/transformations/index.ts b/packages/grafana-data/src/transformations/index.ts index dd80a0e577c..613f04e31bd 100644 --- a/packages/grafana-data/src/transformations/index.ts +++ b/packages/grafana-data/src/transformations/index.ts @@ -12,3 +12,4 @@ export { export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher'; export { RenameByRegexTransformerOptions } from './transformers/renameByRegex'; export { outerJoinDataFrames } from './transformers/joinDataFrames'; +export * from './transformers/histogram'; diff --git a/packages/grafana-data/src/transformations/transformers.ts b/packages/grafana-data/src/transformations/transformers.ts index c76c703e436..deaed1a2e4c 100644 --- a/packages/grafana-data/src/transformations/transformers.ts +++ b/packages/grafana-data/src/transformations/transformers.ts @@ -17,6 +17,7 @@ import { sortByTransformer } from './transformers/sortBy'; import { mergeTransformer } from './transformers/merge'; import { renameByRegexTransformer } from './transformers/renameByRegex'; import { filterByValueTransformer } from './transformers/filterByValue'; +import { histogramTransformer } from './transformers/histogram'; export const standardTransformers = { noopTransformer, @@ -39,4 +40,5 @@ export const standardTransformers = { sortByTransformer, mergeTransformer, renameByRegexTransformer, + histogramTransformer, }; diff --git a/packages/grafana-data/src/transformations/transformers/histogram.test.ts b/packages/grafana-data/src/transformations/transformers/histogram.test.ts new file mode 100644 index 00000000000..883e6252ade --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/histogram.test.ts @@ -0,0 +1,172 @@ +import { toDataFrame } from '../../dataframe/processDataFrame'; +import { FieldType } from '../../types/dataFrame'; +import { mockTransformationsRegistry } from '../../utils/tests/mockTransformationsRegistry'; +import { histogramTransformer, buildHistogram, histogramFieldsToFrame } from './histogram'; + +describe('histogram frames frames', () => { + beforeAll(() => { + mockTransformationsRegistry([histogramTransformer]); + }); + + it('by first time field', () => { + const series1 = toDataFrame({ + fields: [ + { name: 'A', type: FieldType.number, values: [1, 2, 3, 4, 5] }, + { name: 'B', type: FieldType.number, values: [3, 4, 5, 6, 7] }, + { name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }, + ], + }); + + const series2 = toDataFrame({ + fields: [{ name: 'C', type: FieldType.number, values: [5, 6, 7, 8, 9] }], + }); + + const out = histogramFieldsToFrame(buildHistogram([series1, series2])!); + expect( + out.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "BucketMin", + "values": Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + }, + Object { + "name": "BucketMax", + "values": Array [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + }, + Object { + "name": "A", + "values": Array [ + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 0, + 0, + ], + }, + Object { + "name": "B", + "values": Array [ + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 0, + 0, + ], + }, + Object { + "name": "C", + "values": Array [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + ], + }, + Object { + "name": "C", + "values": Array [ + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + ], + }, + ] + `); + + const out2 = histogramFieldsToFrame(buildHistogram([series1, series2], { combine: true })!); + expect( + out2.fields.map((f) => ({ + name: f.name, + values: f.values.toArray(), + })) + ).toMatchInlineSnapshot(` + Array [ + Object { + "name": "BucketMin", + "values": Array [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + ], + }, + Object { + "name": "BucketMax", + "values": Array [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + ], + }, + Object { + "name": "Count", + "values": Array [ + 1, + 1, + 2, + 2, + 4, + 3, + 3, + 2, + 2, + ], + }, + ] + `); + }); +}); diff --git a/packages/grafana-data/src/transformations/transformers/histogram.ts b/packages/grafana-data/src/transformations/transformers/histogram.ts new file mode 100644 index 00000000000..3c0e470a89d --- /dev/null +++ b/packages/grafana-data/src/transformations/transformers/histogram.ts @@ -0,0 +1,306 @@ +import { DataTransformerInfo } from '../../types'; +import { map } from 'rxjs/operators'; + +import { DataTransformerID } from './ids'; +import { DataFrame, Field, FieldType } from '../../types/dataFrame'; +import { ArrayVector } from '../../vector/ArrayVector'; +import { AlignedData, join } from './joinDataFrames'; + +/* eslint-disable */ +// prettier-ignore +/** + * @internal + */ +export const histogramBucketSizes = [ + .001, .002, .0025, .005, + .01, .02, .025, .05, + .1, .2, .25, .5, + 1, 2, 4, 5, + 10, 20, 25, 50, + 100, 200, 250, 500, + 1000, 2000, 2500, 5000, +]; +/* eslint-enable */ + +const histFilter = [null]; +const histSort = (a: number, b: number) => a - b; + +/** + * @alpha + */ +export interface HistogramTransformerOptions { + bucketSize?: number; // 0 is auto + bucketOffset?: number; + // xMin?: number; + // xMax?: number; + combine?: boolean; // if multiple series are input, join them into one +} + +/** + * This is a helper class to use the same text in both a panel and transformer UI + * + * @internal + */ +export const histogramFieldInfo = { + bucketSize: { + name: 'Bucket size', + description: undefined, + }, + bucketOffset: { + name: 'Bucket offset', + description: 'for non-zero-based buckets', + }, + combine: { + name: 'Combine series', + description: 'combine all series into a single histogram', + }, +}; + +/** + * @alpha + */ +export const histogramTransformer: DataTransformerInfo = { + id: DataTransformerID.histogram, + name: 'Histogram', + description: 'Calculate a histogram from input data', + defaultOptions: { + fields: {}, + }, + + /** + * Return a modified copy of the series. If the transform is not or should not + * be applied, just return the input series + */ + operator: (options) => (source) => + source.pipe( + map((data) => { + if (!Array.isArray(data) || data.length === 0) { + return data; + } + const hist = buildHistogram(data, options); + if (hist == null) { + return []; + } + return [histogramFieldsToFrame(hist)]; + }) + ), +}; + +/** + * @internal + */ +export const histogramFrameBucketMinFieldName = 'BucketMin'; + +/** + * @internal + */ +export const histogramFrameBucketMaxFieldName = 'BucketMax'; + +/** + * @alpha + */ +export interface HistogramFields { + bucketMin: Field; + bucketMax: Field; + counts: Field[]; // frequency +} + +/** + * Given a frame, find the explicit histogram fields + * + * @alpha + */ +export function getHistogramFields(frame: DataFrame): HistogramFields | undefined { + let bucketMin: Field | undefined = undefined; + let bucketMax: Field | undefined = undefined; + const counts: Field[] = []; + for (const field of frame.fields) { + if (field.name === histogramFrameBucketMinFieldName) { + bucketMin = field; + } else if (field.name === histogramFrameBucketMaxFieldName) { + bucketMax = field; + } else if (field.type === FieldType.number) { + counts.push(field); + } + } + if (bucketMin && bucketMax && counts.length) { + return { + bucketMin, + bucketMax, + counts, + }; + } + return undefined; +} + +/** + * @alpha + */ +export function buildHistogram(frames: DataFrame[], options?: HistogramTransformerOptions): HistogramFields | null { + let bucketSize = options?.bucketSize; + let bucketOffset = options?.bucketOffset ?? 0; + + // if bucket size is auto, try to calc from all numeric fields + if (!bucketSize) { + let min = Infinity, + max = -Infinity; + + // TODO: include field configs! + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + for (const value of field.values.toArray()) { + min = Math.min(min, value); + max = Math.max(max, value); + } + } + } + } + + let range = Math.abs(max - min); + + // choose bucket + for (const size of histogramBucketSizes) { + if (range / 10 < size) { + bucketSize = size; + break; + } + } + } + + const getBucket = (v: number) => incrRoundDn(v - bucketOffset, bucketSize!) + bucketOffset; + + let histograms: AlignedData[] = []; + let counts: Field[] = []; + + for (const frame of frames) { + for (const field of frame.fields) { + if (field.type === FieldType.number) { + let fieldHist = histogram(field.values.toArray(), getBucket, histFilter, histSort) as AlignedData; + histograms.push(fieldHist); + counts.push({ ...field }); + } + } + } + + // Quit early for empty a + if (!counts.length) { + return null; + } + + // align histograms + let joinedHists = join(histograms); + + // zero-fill all undefined values (missing buckets -> 0 counts) + for (let histIdx = 1; histIdx < joinedHists.length; histIdx++) { + let hist = joinedHists[histIdx]; + + for (let bucketIdx = 0; bucketIdx < hist.length; bucketIdx++) { + if (hist[bucketIdx] == null) { + hist[bucketIdx] = 0; + } + } + } + + const bucketMin = { + name: histogramFrameBucketMinFieldName, + values: new ArrayVector(joinedHists[0]), + type: FieldType.number, + config: {}, + }; + const bucketMax = { + name: histogramFrameBucketMaxFieldName, + values: new ArrayVector(joinedHists[0].map((v) => v + bucketSize!)), + type: FieldType.number, + config: {}, + }; + + if (options?.combine) { + const vals = new Array(joinedHists[0].length).fill(0); + for (let i = 1; i < joinedHists.length; i++) { + for (let j = 0; j < vals.length; j++) { + vals[j] += joinedHists[i][j]; + } + } + counts = [ + { + ...counts[0], + name: 'Count', + values: new ArrayVector(vals), + }, + ]; + } else { + counts.forEach((field, i) => { + field.values = new ArrayVector(joinedHists[i + 1]); + }); + } + + return { + bucketMin, + bucketMax, + counts, + }; +} + +// function incrRound(num: number, incr: number) { +// return Math.round(num / incr) * incr; +// } + +// function incrRoundUp(num: number, incr: number) { +// return Math.ceil(num / incr) * incr; +// } + +function incrRoundDn(num: number, incr: number) { + return Math.floor(num / incr) * incr; +} + +function histogram( + vals: number[], + getBucket: (v: number) => number, + filterOut?: any[] | null, + sort?: ((a: any, b: any) => number) | null +) { + let hist = new Map(); + + for (let i = 0; i < vals.length; i++) { + let v = vals[i]; + + if (v != null) { + v = getBucket(v); + } + + let entry = hist.get(v); + + if (entry) { + entry.count++; + } else { + hist.set(v, { value: v, count: 1 }); + } + } + + filterOut && filterOut.forEach((v) => hist.delete(v)); + + let bins = [...hist.values()]; + + sort && bins.sort((a, b) => sort(a.value, b.value)); + + let values = Array(bins.length); + let counts = Array(bins.length); + + for (let i = 0; i < bins.length; i++) { + values[i] = bins[i].value; + counts[i] = bins[i].count; + } + + return [values, counts]; +} + +/** + * @internal + */ +export function histogramFieldsToFrame(info: HistogramFields): DataFrame { + return { + fields: [info.bucketMin, info.bucketMax, ...info.counts], + length: info.bucketMin.values.length, + }; +} diff --git a/packages/grafana-data/src/transformations/transformers/ids.ts b/packages/grafana-data/src/transformations/transformers/ids.ts index 1aaf5773610..f62da205690 100644 --- a/packages/grafana-data/src/transformations/transformers/ids.ts +++ b/packages/grafana-data/src/transformations/transformers/ids.ts @@ -22,4 +22,5 @@ export enum DataTransformerID { ensureColumns = 'ensureColumns', groupBy = 'groupBy', sortBy = 'sortBy', + histogram = 'histogram', } diff --git a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts index c8e668bc10d..be0a7647ad9 100644 --- a/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts +++ b/packages/grafana-data/src/transformations/transformers/joinDataFrames.ts @@ -216,7 +216,7 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined //-------------------------------------------------------------------------------- // Copied from uplot -type AlignedData = [number[], ...Array>]; +export type AlignedData = [number[], ...Array>]; // nullModes const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true) @@ -245,7 +245,7 @@ function nullExpand(yVals: Array, nullIdxs: number[], alignedLen: } // nullModes is a tables-matched array indicating how to treat nulls in each series -function join(tables: AlignedData[], nullModes: number[][]) { +export function join(tables: AlignedData[], nullModes?: number[][]) { const xVals = new Set(); for (let ti = 0; ti < tables.length; ti++) { diff --git a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts index 9b1a4818212..5842cdb87f7 100644 --- a/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts +++ b/packages/grafana-ui/src/components/uPlot/config/UPlotAxisBuilder.ts @@ -16,6 +16,7 @@ export interface AxisProps { grid?: boolean; ticks?: boolean; formatValue?: (v: any) => string; + incrs?: Axis.Incrs; splits?: Axis.Splits; values?: any; isTime?: boolean; diff --git a/public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx b/public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx new file mode 100644 index 00000000000..0b65a9fc8ae --- /dev/null +++ b/public/app/core/components/TransformersUI/HistogramTransformerEditor.tsx @@ -0,0 +1,91 @@ +import React, { FormEvent, useCallback } from 'react'; +import { DataTransformerID, standardTransformers, TransformerRegistryItem, TransformerUIProps } from '@grafana/data'; + +import { + HistogramTransformerOptions, + histogramFieldInfo, +} from '@grafana/data/src/transformations/transformers/histogram'; +import { InlineField, InlineFieldRow, InlineSwitch, Input } from '@grafana/ui'; + +export const HistogramTransformerEditor: React.FC> = ({ + input, + options, + onChange, +}) => { + const labelWidth = 18; + + const onBucketSizeChanged = useCallback( + (evt: FormEvent) => { + const val = evt.currentTarget.valueAsNumber; + onChange({ + ...options, + bucketSize: isNaN(val) ? undefined : val, + }); + }, + [onChange, options] + ); + + const onBucketOffsetChanged = useCallback( + (evt: FormEvent) => { + const val = evt.currentTarget.valueAsNumber; + onChange({ + ...options, + bucketOffset: isNaN(val) ? undefined : val, + }); + }, + [onChange, options] + ); + + const onToggleCombine = useCallback(() => { + onChange({ + ...options, + combine: !options.combine, + }); + }, [onChange, options]); + + return ( +
+ + + + + + + + + + + + + + + +
+ ); +}; + +export const histogramTransformRegistryItem: TransformerRegistryItem = { + id: DataTransformerID.histogram, + editor: HistogramTransformerEditor, + transformation: standardTransformers.histogramTransformer, + name: standardTransformers.histogramTransformer.name, + description: standardTransformers.histogramTransformer.description, +}; diff --git a/public/app/core/utils/standardTransformers.ts b/public/app/core/utils/standardTransformers.ts index fe5429fde35..584ed687472 100644 --- a/public/app/core/utils/standardTransformers.ts +++ b/public/app/core/utils/standardTransformers.ts @@ -13,6 +13,7 @@ import { mergeTransformerRegistryItem } from '../components/TransformersUI/Merge import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor'; import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor'; import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer'; +import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor'; export const getStandardTransformers = (): Array> => { return [ @@ -30,5 +31,6 @@ export const getStandardTransformers = (): Array> = groupByTransformRegistryItem, sortByTransformRegistryItem, mergeTransformerRegistryItem, + histogramTransformRegistryItem, ]; }; diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 78dc0897239..2b68e190131 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -64,6 +64,7 @@ import * as livePanel from 'app/plugins/panel/live/module'; import * as debugPanel from 'app/plugins/panel/debug/module'; import * as welcomeBanner from 'app/plugins/panel/welcome/module'; import * as nodeGraph from 'app/plugins/panel/nodeGraph/module'; +import * as histogramPanel from 'app/plugins/panel/histogram/module'; const builtInPlugins: any = { 'app/plugins/datasource/graphite/module': graphitePlugin, @@ -111,6 +112,7 @@ const builtInPlugins: any = { 'app/plugins/panel/logs/module': logsPanel, 'app/plugins/panel/welcome/module': welcomeBanner, 'app/plugins/panel/nodeGraph/module': nodeGraph, + 'app/plugins/panel/histogram/module': histogramPanel, }; export default builtInPlugins; diff --git a/public/app/plugins/panel/histogram/Histogram.tsx b/public/app/plugins/panel/histogram/Histogram.tsx new file mode 100644 index 00000000000..d0fe9caec01 --- /dev/null +++ b/public/app/plugins/panel/histogram/Histogram.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import uPlot, { AlignedData } from 'uplot'; +import { + DataFrame, + getFieldColorModeForField, + getFieldDisplayName, + getFieldSeriesColor, + GrafanaTheme2, + histogramBucketSizes, +} from '@grafana/data'; +import { + Themeable2, + UPlotConfigBuilder, + VizLegendOptions, + UPlotChart, + VizLayout, + AxisPlacement, + ScaleDirection, + ScaleDistribution, + ScaleOrientation, +} from '@grafana/ui'; + +import { histogramFrameBucketMaxFieldName } from '@grafana/data/src/transformations/transformers/histogram'; +import { PanelOptions } from './models.gen'; + +export interface HistogramProps extends Themeable2 { + options: PanelOptions; // used for diff + alignedFrame: DataFrame; // This could take HistogramFields + width: number; + height: number; + structureRev?: number; // a number that will change when the frames[] structure changes + legend: VizLegendOptions; + //onLegendClick?: (event: GraphNGLegendEvent) => void; + children?: (builder: UPlotConfigBuilder, frame: DataFrame) => React.ReactNode; + + //prepConfig: (frame: DataFrame) => UPlotConfigBuilder; + //propsToDiff?: string[]; + //renderLegend: (config: UPlotConfigBuilder) => React.ReactElement; +} + +const prepConfig = (frame: DataFrame, theme: GrafanaTheme2) => { + // todo: scan all values in BucketMin and BucketMax fields to assert if uniform bucketSize + + let builder = new UPlotConfigBuilder(); + + // assumes BucketMin is fields[0] and BucktMax is fields[1] + let bucketSize = frame.fields[1].values.get(0) - frame.fields[0].values.get(0); + + // splits shifter, to ensure splits always start at first bucket + let xSplits: uPlot.Axis.Splits = (u, axisIdx, scaleMin, scaleMax, foundIncr, foundSpace) => { + /** @ts-ignore */ + let minSpace = u.axes[axisIdx]._space; + let bucketWidth = u.valToPos(u.data[0][0] + bucketSize, 'x') - u.valToPos(u.data[0][0], 'x'); + + let firstSplit = u.data[0][0]; + let lastSplit = u.data[0][u.data[0].length - 1] + bucketSize; + + let splits = []; + let skip = Math.ceil(minSpace / bucketWidth); + + for (let i = 0, s = firstSplit; s <= lastSplit; i++, s += bucketSize) { + !(i % skip) && splits.push(s); + } + + return splits; + }; + + builder.addScale({ + scaleKey: 'x', // bukkits + isTime: false, + distribution: ScaleDistribution.Linear, + orientation: ScaleOrientation.Horizontal, + direction: ScaleDirection.Right, + range: (u) => [u.data[0][0], u.data[0][u.data[0].length - 1] + bucketSize], + }); + + builder.addScale({ + scaleKey: 'y', // counts + isTime: false, + distribution: ScaleDistribution.Linear, + orientation: ScaleOrientation.Vertical, + direction: ScaleDirection.Up, + }); + + builder.addAxis({ + scaleKey: 'x', + isTime: false, + placement: AxisPlacement.Bottom, + incrs: histogramBucketSizes, + splits: xSplits, + //incrs: () => [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((mult) => mult * bucketSize), + //splits: config.xSplits, + //values: config.xValues, + //grid: false, + //ticks: false, + //gap: 15, + theme, + }); + + builder.addAxis({ + scaleKey: 'y', + isTime: false, + placement: AxisPlacement.Left, + //splits: config.xSplits, + //values: config.xValues, + //grid: false, + //ticks: false, + //gap: 15, + theme, + }); + + let pathBuilder = uPlot.paths.bars!({ align: 1, size: [1, Infinity] }); + + let seriesIndex = 0; + + // assumes BucketMax is [1] + for (let i = 2; i < frame.fields.length; i++) { + const field = frame.fields[i]; + + field.state!.seriesIndex = seriesIndex++; + + const customConfig = { ...field.config.custom }; + + const scaleKey = 'y'; + const colorMode = getFieldColorModeForField(field); + const scaleColor = getFieldSeriesColor(field, theme); + const seriesColor = scaleColor.color; + + builder.addSeries({ + scaleKey, + lineWidth: customConfig.lineWidth, + lineColor: seriesColor, + //lineStyle: customConfig.lineStyle, + fillOpacity: customConfig.fillOpacity, + theme, + colorMode, + pathBuilder, + //pointsBuilder: config.drawPoints, + show: !customConfig.hideFrom?.graph, + gradientMode: customConfig.gradientMode, + thresholds: field.config.thresholds, + + // The following properties are not used in the uPlot config, but are utilized as transport for legend config + // dataFrameFieldIndex: { + // fieldIndex: i, + // frameIndex: 0, + // }, + fieldName: getFieldDisplayName(field, frame), + hideInLegend: customConfig.hideFrom?.legend, + }); + } + + return builder; +}; + +const preparePlotData = (frame: DataFrame) => { + let data: AlignedData = [] as any; + + for (const field of frame.fields) { + if (field.name !== histogramFrameBucketMaxFieldName) { + data.push(field.values.toArray()); + } + } + + // uPlot's bars pathBuilder will draw rects even if 0 (to distinguish them from nulls) + // but for histograms we want to omit them, so remap 0s -> nulls + for (let i = 1; i < data.length; i++) { + let counts = data[i]; + for (let j = 0; j < counts.length; j++) { + if (counts[j] === 0) { + counts[j] = null; + } + } + } + + return data; +}; + +const renderLegend = (config: UPlotConfigBuilder) => { + return null; +}; + +interface State { + alignedData: AlignedData; + config?: UPlotConfigBuilder; +} + +export class Histogram extends React.Component { + constructor(props: HistogramProps) { + super(props); + this.state = this.prepState(props); + } + + prepState(props: HistogramProps, withConfig = true) { + let state: State = null as any; + + const { alignedFrame } = props; + if (alignedFrame) { + state = { + alignedData: preparePlotData(alignedFrame), + }; + + if (withConfig) { + state.config = prepConfig(alignedFrame, this.props.theme); + } + } + + return state; + } + + componentDidUpdate(prevProps: HistogramProps) { + const { structureRev, alignedFrame } = this.props; + + if (alignedFrame !== prevProps.alignedFrame) { + let newState = this.prepState(this.props, false); + + if (newState) { + const shouldReconfig = + this.props.options !== prevProps.options || + this.state.config === undefined || + structureRev !== prevProps.structureRev || + !structureRev; + + if (shouldReconfig) { + newState.config = prepConfig(alignedFrame, this.props.theme); + } + } + + newState && this.setState(newState); + } + } + + render() { + const { width, height, children, alignedFrame } = this.props; + const { config } = this.state; + + if (!config) { + return null; + } + + return ( + + {(vizWidth: number, vizHeight: number) => ( + + {children ? children(config, alignedFrame) : null} + + )} + + ); + } +} diff --git a/public/app/plugins/panel/histogram/HistogramPanel.tsx b/public/app/plugins/panel/histogram/HistogramPanel.tsx new file mode 100644 index 00000000000..76dd3c67141 --- /dev/null +++ b/public/app/plugins/panel/histogram/HistogramPanel.tsx @@ -0,0 +1,51 @@ +import React, { useMemo } from 'react'; +import { PanelProps, buildHistogram, getHistogramFields } from '@grafana/data'; + +import { Histogram } from './Histogram'; +import { PanelOptions } from './models.gen'; +import { useTheme2 } from '@grafana/ui'; + +type Props = PanelProps; + +import { histogramFieldsToFrame } from '@grafana/data/src/transformations/transformers/histogram'; + +export const HistogramPanel: React.FC = ({ data, options, width, height }) => { + const theme = useTheme2(); + + const histogram = useMemo(() => { + if (!data?.series?.length) { + return undefined; + } + if (data.series.length === 1) { + const info = getHistogramFields(data.series[0]); + if (info) { + return histogramFieldsToFrame(info); + } + } + const hist = buildHistogram(data.series, options); + if (!hist) { + return undefined; + } + return histogramFieldsToFrame(hist); + }, [data.series, options]); + + if (!histogram || !histogram.fields.length) { + return ( +
+

No histogram found in response

+
+ ); + } + + return ( + + ); +}; diff --git a/public/app/plugins/panel/histogram/img/histogram.svg b/public/app/plugins/panel/histogram/img/histogram.svg new file mode 100644 index 00000000000..1f035cd8ef1 --- /dev/null +++ b/public/app/plugins/panel/histogram/img/histogram.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/app/plugins/panel/histogram/models.cue b/public/app/plugins/panel/histogram/models.cue new file mode 100644 index 00000000000..ca9f4a2e1fd --- /dev/null +++ b/public/app/plugins/panel/histogram/models.cue @@ -0,0 +1,18 @@ +package grafanaschema + +Family: { + lineages: [ + [ + { + PanelOptions: { + bucketSize?: int + bucketOffset: int | *0 + combine?: bool + } + + // TODO: FieldConfig + } + ] + ] + migrations: [] +} diff --git a/public/app/plugins/panel/histogram/models.gen.ts b/public/app/plugins/panel/histogram/models.gen.ts new file mode 100644 index 00000000000..4eea7de6f95 --- /dev/null +++ b/public/app/plugins/panel/histogram/models.gen.ts @@ -0,0 +1,36 @@ +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// NOTE: This file will be auto generated from models.cue +// It is currenty hand written but will serve as the target for cuetsy +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +import { GraphGradientMode } from '@grafana/ui'; + +export const modelVersion = Object.freeze([1, 0]); + +export interface PanelOptions { + bucketSize?: number; + bucketOffset?: number; + combine?: boolean; +} + +export const defaultPanelOptions: PanelOptions = { + bucketOffset: 0, +}; + +/** + * @alpha + */ +export interface PanelFieldConfig { + lineWidth?: number; // 0 + fillOpacity?: number; // 100 + gradientMode?: GraphGradientMode; +} + +/** + * @alpha + */ +export const defaultPanelFieldConfig: PanelFieldConfig = { + lineWidth: 1, + fillOpacity: 80, + //gradientMode: GraphGradientMode.None, +}; diff --git a/public/app/plugins/panel/histogram/module.tsx b/public/app/plugins/panel/histogram/module.tsx new file mode 100644 index 00000000000..df38a184bda --- /dev/null +++ b/public/app/plugins/panel/histogram/module.tsx @@ -0,0 +1,92 @@ +import { FieldColorModeId, FieldConfigProperty, PanelPlugin } from '@grafana/data'; +import { HistogramPanel } from './HistogramPanel'; +import { graphFieldOptions } from '@grafana/ui'; +import { PanelFieldConfig, PanelOptions, defaultPanelFieldConfig, defaultPanelOptions } from './models.gen'; +import { originalDataHasHistogram } from './utils'; + +import { histogramFieldInfo } from '@grafana/data/src/transformations/transformers/histogram'; + +export const plugin = new PanelPlugin(HistogramPanel) + .setPanelOptions((builder) => { + builder + .addCustomEditor({ + id: '__calc__', + path: '__calc__', + name: 'Values', + description: 'Showing frequencies that are calculated in the query', + editor: () => null, // empty editor + showIf: (opts, data) => originalDataHasHistogram(data), + }) + .addNumberInput({ + path: 'bucketSize', + name: histogramFieldInfo.bucketSize.name, + description: histogramFieldInfo.bucketSize.description, + settings: { + placeholder: 'Auto', + }, + defaultValue: defaultPanelOptions.bucketSize, + showIf: (opts, data) => !originalDataHasHistogram(data), + }) + .addNumberInput({ + path: 'bucketOffset', + name: histogramFieldInfo.bucketOffset.name, + description: histogramFieldInfo.bucketOffset.description, + settings: { + placeholder: '0', + }, + defaultValue: defaultPanelOptions.bucketOffset, + showIf: (opts, data) => !originalDataHasHistogram(data), + }) + .addBooleanSwitch({ + path: 'combine', + name: histogramFieldInfo.combine.name, + description: histogramFieldInfo.combine.description, + defaultValue: defaultPanelOptions.combine, + showIf: (opts, data) => !originalDataHasHistogram(data), + }); + }) + .useFieldConfig({ + standardOptions: { + [FieldConfigProperty.Color]: { + settings: { + byValueSupport: false, + }, + defaultValue: { + mode: FieldColorModeId.PaletteClassic, + }, + }, + }, + useCustomConfig: (builder) => { + const cfg = defaultPanelFieldConfig; + + builder + .addSliderInput({ + path: 'lineWidth', + name: 'Line width', + defaultValue: cfg.lineWidth, + settings: { + min: 0, + max: 10, + step: 1, + }, + }) + .addSliderInput({ + path: 'fillOpacity', + name: 'Fill opacity', + defaultValue: cfg.fillOpacity, + settings: { + min: 0, + max: 100, + step: 1, + }, + }) + .addRadio({ + path: 'gradientMode', + name: 'Gradient mode', + defaultValue: graphFieldOptions.fillGradient[0].value, + settings: { + options: graphFieldOptions.fillGradient, + }, + }); + }, + }); diff --git a/public/app/plugins/panel/histogram/plugin.json b/public/app/plugins/panel/histogram/plugin.json new file mode 100644 index 00000000000..3aec3df24f4 --- /dev/null +++ b/public/app/plugins/panel/histogram/plugin.json @@ -0,0 +1,18 @@ +{ + "type": "panel", + "name": "Histogram", + "id": "histogram", + + "state": "alpha", + + "info": { + "author": { + "name": "Grafana Labs", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/histogram.svg", + "large": "img/histogram.svg" + } + } +} diff --git a/public/app/plugins/panel/histogram/utils.ts b/public/app/plugins/panel/histogram/utils.ts new file mode 100644 index 00000000000..5f6de56ab79 --- /dev/null +++ b/public/app/plugins/panel/histogram/utils.ts @@ -0,0 +1,30 @@ +import { DataFrame, FieldType } from '@grafana/data'; + +import { + histogramFrameBucketMinFieldName, + histogramFrameBucketMaxFieldName, +} from '@grafana/data/src/transformations/transformers/histogram'; + +export function originalDataHasHistogram(frames?: DataFrame[]): boolean { + if (frames?.length !== 1) { + return false; + } + const frame = frames[0]; + if (frame.fields.length < 3) { + return false; + } + + if ( + frame.fields[0].name !== histogramFrameBucketMinFieldName || + frame.fields[1].name !== histogramFrameBucketMaxFieldName + ) { + return false; + } + for (const field of frame.fields) { + if (field.type !== FieldType.number) { + return false; + } + } + + return true; +}