mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
New Panel: Histogram (#33752)
This commit is contained in:
parent
a40946b6aa
commit
5fd7c34420
@ -12,3 +12,4 @@ export {
|
|||||||
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
|
export { RegexpOrNamesMatcherOptions, ByNamesMatcherOptions, ByNamesMatcherMode } from './matchers/nameMatcher';
|
||||||
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
|
export { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
|
||||||
export { outerJoinDataFrames } from './transformers/joinDataFrames';
|
export { outerJoinDataFrames } from './transformers/joinDataFrames';
|
||||||
|
export * from './transformers/histogram';
|
||||||
|
@ -17,6 +17,7 @@ import { sortByTransformer } from './transformers/sortBy';
|
|||||||
import { mergeTransformer } from './transformers/merge';
|
import { mergeTransformer } from './transformers/merge';
|
||||||
import { renameByRegexTransformer } from './transformers/renameByRegex';
|
import { renameByRegexTransformer } from './transformers/renameByRegex';
|
||||||
import { filterByValueTransformer } from './transformers/filterByValue';
|
import { filterByValueTransformer } from './transformers/filterByValue';
|
||||||
|
import { histogramTransformer } from './transformers/histogram';
|
||||||
|
|
||||||
export const standardTransformers = {
|
export const standardTransformers = {
|
||||||
noopTransformer,
|
noopTransformer,
|
||||||
@ -39,4 +40,5 @@ export const standardTransformers = {
|
|||||||
sortByTransformer,
|
sortByTransformer,
|
||||||
mergeTransformer,
|
mergeTransformer,
|
||||||
renameByRegexTransformer,
|
renameByRegexTransformer,
|
||||||
|
histogramTransformer,
|
||||||
};
|
};
|
||||||
|
@ -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,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
});
|
@ -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<HistogramTransformerOptions> = {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
@ -22,4 +22,5 @@ export enum DataTransformerID {
|
|||||||
ensureColumns = 'ensureColumns',
|
ensureColumns = 'ensureColumns',
|
||||||
groupBy = 'groupBy',
|
groupBy = 'groupBy',
|
||||||
sortBy = 'sortBy',
|
sortBy = 'sortBy',
|
||||||
|
histogram = 'histogram',
|
||||||
}
|
}
|
||||||
|
@ -216,7 +216,7 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined
|
|||||||
//--------------------------------------------------------------------------------
|
//--------------------------------------------------------------------------------
|
||||||
|
|
||||||
// Copied from uplot
|
// Copied from uplot
|
||||||
type AlignedData = [number[], ...Array<Array<number | null>>];
|
export type AlignedData = [number[], ...Array<Array<number | null>>];
|
||||||
|
|
||||||
// nullModes
|
// nullModes
|
||||||
const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true)
|
const NULL_REMOVE = 0; // nulls are converted to undefined (e.g. for spanGaps: true)
|
||||||
@ -245,7 +245,7 @@ function nullExpand(yVals: Array<number | null>, nullIdxs: number[], alignedLen:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// nullModes is a tables-matched array indicating how to treat nulls in each series
|
// 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<number>();
|
const xVals = new Set<number>();
|
||||||
|
|
||||||
for (let ti = 0; ti < tables.length; ti++) {
|
for (let ti = 0; ti < tables.length; ti++) {
|
||||||
|
@ -16,6 +16,7 @@ export interface AxisProps {
|
|||||||
grid?: boolean;
|
grid?: boolean;
|
||||||
ticks?: boolean;
|
ticks?: boolean;
|
||||||
formatValue?: (v: any) => string;
|
formatValue?: (v: any) => string;
|
||||||
|
incrs?: Axis.Incrs;
|
||||||
splits?: Axis.Splits;
|
splits?: Axis.Splits;
|
||||||
values?: any;
|
values?: any;
|
||||||
isTime?: boolean;
|
isTime?: boolean;
|
||||||
|
@ -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<TransformerUIProps<HistogramTransformerOptions>> = ({
|
||||||
|
input,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}) => {
|
||||||
|
const labelWidth = 18;
|
||||||
|
|
||||||
|
const onBucketSizeChanged = useCallback(
|
||||||
|
(evt: FormEvent<HTMLInputElement>) => {
|
||||||
|
const val = evt.currentTarget.valueAsNumber;
|
||||||
|
onChange({
|
||||||
|
...options,
|
||||||
|
bucketSize: isNaN(val) ? undefined : val,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onChange, options]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onBucketOffsetChanged = useCallback(
|
||||||
|
(evt: FormEvent<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<InlineFieldRow>
|
||||||
|
<InlineField
|
||||||
|
labelWidth={labelWidth}
|
||||||
|
label={histogramFieldInfo.bucketSize.name}
|
||||||
|
tooltip={histogramFieldInfo.bucketSize.description}
|
||||||
|
>
|
||||||
|
<Input type="number" value={options.bucketSize} placeholder="auto" onChange={onBucketSizeChanged} min={0} />
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
<InlineFieldRow>
|
||||||
|
<InlineField
|
||||||
|
labelWidth={labelWidth}
|
||||||
|
label={histogramFieldInfo.bucketOffset.name}
|
||||||
|
tooltip={histogramFieldInfo.bucketOffset.description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={options.bucketOffset}
|
||||||
|
placeholder="none"
|
||||||
|
onChange={onBucketOffsetChanged}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
<InlineFieldRow>
|
||||||
|
<InlineField
|
||||||
|
labelWidth={labelWidth}
|
||||||
|
label={histogramFieldInfo.combine.name}
|
||||||
|
tooltip={histogramFieldInfo.combine.description}
|
||||||
|
>
|
||||||
|
<InlineSwitch value={options.combine ?? false} onChange={onToggleCombine} />
|
||||||
|
</InlineField>
|
||||||
|
</InlineFieldRow>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const histogramTransformRegistryItem: TransformerRegistryItem<HistogramTransformerOptions> = {
|
||||||
|
id: DataTransformerID.histogram,
|
||||||
|
editor: HistogramTransformerEditor,
|
||||||
|
transformation: standardTransformers.histogramTransformer,
|
||||||
|
name: standardTransformers.histogramTransformer.name,
|
||||||
|
description: standardTransformers.histogramTransformer.description,
|
||||||
|
};
|
@ -13,6 +13,7 @@ import { mergeTransformerRegistryItem } from '../components/TransformersUI/Merge
|
|||||||
import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor';
|
import { seriesToRowsTransformerRegistryItem } from '../components/TransformersUI/SeriesToRowsTransformerEditor';
|
||||||
import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
|
import { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
|
||||||
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
|
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
|
||||||
|
import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
|
||||||
|
|
||||||
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
|
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
|
||||||
return [
|
return [
|
||||||
@ -30,5 +31,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
|
|||||||
groupByTransformRegistryItem,
|
groupByTransformRegistryItem,
|
||||||
sortByTransformRegistryItem,
|
sortByTransformRegistryItem,
|
||||||
mergeTransformerRegistryItem,
|
mergeTransformerRegistryItem,
|
||||||
|
histogramTransformRegistryItem,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
@ -64,6 +64,7 @@ import * as livePanel from 'app/plugins/panel/live/module';
|
|||||||
import * as debugPanel from 'app/plugins/panel/debug/module';
|
import * as debugPanel from 'app/plugins/panel/debug/module';
|
||||||
import * as welcomeBanner from 'app/plugins/panel/welcome/module';
|
import * as welcomeBanner from 'app/plugins/panel/welcome/module';
|
||||||
import * as nodeGraph from 'app/plugins/panel/nodeGraph/module';
|
import * as nodeGraph from 'app/plugins/panel/nodeGraph/module';
|
||||||
|
import * as histogramPanel from 'app/plugins/panel/histogram/module';
|
||||||
|
|
||||||
const builtInPlugins: any = {
|
const builtInPlugins: any = {
|
||||||
'app/plugins/datasource/graphite/module': graphitePlugin,
|
'app/plugins/datasource/graphite/module': graphitePlugin,
|
||||||
@ -111,6 +112,7 @@ const builtInPlugins: any = {
|
|||||||
'app/plugins/panel/logs/module': logsPanel,
|
'app/plugins/panel/logs/module': logsPanel,
|
||||||
'app/plugins/panel/welcome/module': welcomeBanner,
|
'app/plugins/panel/welcome/module': welcomeBanner,
|
||||||
'app/plugins/panel/nodeGraph/module': nodeGraph,
|
'app/plugins/panel/nodeGraph/module': nodeGraph,
|
||||||
|
'app/plugins/panel/histogram/module': histogramPanel,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default builtInPlugins;
|
export default builtInPlugins;
|
||||||
|
257
public/app/plugins/panel/histogram/Histogram.tsx
Normal file
257
public/app/plugins/panel/histogram/Histogram.tsx
Normal file
@ -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<HistogramProps, State> {
|
||||||
|
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 (
|
||||||
|
<VizLayout width={width} height={height} legend={renderLegend(config) as any}>
|
||||||
|
{(vizWidth: number, vizHeight: number) => (
|
||||||
|
<UPlotChart
|
||||||
|
config={this.state.config!}
|
||||||
|
data={this.state.alignedData}
|
||||||
|
width={vizWidth}
|
||||||
|
height={vizHeight}
|
||||||
|
timeRange={null as any}
|
||||||
|
>
|
||||||
|
{children ? children(config, alignedFrame) : null}
|
||||||
|
</UPlotChart>
|
||||||
|
)}
|
||||||
|
</VizLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
51
public/app/plugins/panel/histogram/HistogramPanel.tsx
Normal file
51
public/app/plugins/panel/histogram/HistogramPanel.tsx
Normal file
@ -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<PanelOptions>;
|
||||||
|
|
||||||
|
import { histogramFieldsToFrame } from '@grafana/data/src/transformations/transformers/histogram';
|
||||||
|
|
||||||
|
export const HistogramPanel: React.FC<Props> = ({ 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 (
|
||||||
|
<div className="panel-empty">
|
||||||
|
<p>No histogram found in response</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Histogram
|
||||||
|
options={options}
|
||||||
|
theme={theme}
|
||||||
|
legend={null as any} // TODO!
|
||||||
|
structureRev={data.structureRev}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
alignedFrame={histogram}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
1
public/app/plugins/panel/histogram/img/histogram.svg
Normal file
1
public/app/plugins/panel/histogram/img/histogram.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 78.69 80.31"><defs><style>.cls-1{fill:url(#linear-gradient);}.cls-2{fill:#3865ab;}</style><linearGradient id="linear-gradient" y1="43.57" x2="78.69" y2="43.57" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f2cc0c"/><stop offset="1" stop-color="#ff9830"/></linearGradient></defs><g id="Layer_2" data-name="Layer 2"><g id="Icons"><path class="cls-1" d="M23.16,80.31h-4a1,1,0,0,1-1-1V48.21a1,1,0,0,1,1-1h4a1,1,0,0,1,1,1v31.1A1,1,0,0,1,23.16,80.31Zm-9.07-12h-4a1,1,0,0,0-1,1v10a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1v-10A1,1,0,0,0,14.09,68.32ZM32.23,16.61h-4a1,1,0,0,0-1,1v61.7a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V17.61A1,1,0,0,0,32.23,16.61ZM41.3,6.82h-4a1,1,0,0,0-1,1V79.31a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V7.82A1,1,0,0,0,41.3,6.82Zm9.07,9.79h-4a1,1,0,0,0-1,1v61.7a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V17.61A1,1,0,0,0,50.37,16.61Zm9.07,30.6h-4a1,1,0,0,0-1,1v31.1a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V48.21A1,1,0,0,0,59.44,47.21Zm9.07,21.11h-4a1,1,0,0,0-1,1v10a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1v-10A1,1,0,0,0,68.51,68.32Zm9.18,4.81h-4a1,1,0,0,0-1,1v5.18a1,1,0,0,0,1,1h4a1,1,0,0,0,1-1V74.13A1,1,0,0,0,77.69,73.13ZM5,73.13H1a1,1,0,0,0-1,1v5.18a1,1,0,0,0,1,1H5a1,1,0,0,0,1-1V74.13A1,1,0,0,0,5,73.13Z"/><path class="cls-2" d="M0,70.31v-4a6.87,6.87,0,0,0,2.29-.37L3.6,69.72A11,11,0,0,1,0,70.31Z"/><path class="cls-2" d="M8.54,66.35l-3-2.69A19.14,19.14,0,0,0,8.5,59.07l3.61,1.72A22.79,22.79,0,0,1,8.54,66.35Zm61.55-.07a23.07,23.07,0,0,1-3.55-5.56L70.16,59a19.22,19.22,0,0,0,2.9,4.6Zm-56-10.39L10.28,54.6c.57-1.66,1.12-3.51,1.69-5.67l3.87,1C15.24,52.2,14.67,54.15,14.07,55.89Zm50.52-.07c-.59-1.74-1.17-3.69-1.76-5.94l3.87-1c.57,2.16,1.12,4,1.68,5.67ZM17.06,45l-3.89-.91c.45-1.93.88-3.9,1.32-5.89l3.91.87C18,41.07,17.51,43.06,17.06,45Zm44.55-.08C61.16,43,60.72,41,60.27,39l3.91-.87c.44,2,.88,4,1.32,5.89ZM19.5,34.15l-3.9-.89c.52-2.3,1-4.19,1.42-5.94l3.88,1C20.46,30,20,31.88,19.5,34.15Zm39.67-.08c-.53-2.32-1-4.17-1.4-5.84l3.88-1c.43,1.7.89,3.59,1.42,5.94Zm-37-10.55-3.83-1.14c.65-2.19,1.3-4.11,2-5.89l3.73,1.44C23.45,19.61,22.83,21.44,22.21,23.52Zm34.24-.09c-.62-2.07-1.23-3.89-1.89-5.58l3.73-1.45c.69,1.79,1.35,3.72,2,5.89ZM26.06,13.52,22.5,11.69A30.32,30.32,0,0,1,26,6.26L29.1,8.82A26.32,26.32,0,0,0,26.06,13.52Zm26.53-.07a26.42,26.42,0,0,0-3.05-4.69L52.6,6.19a29.61,29.61,0,0,1,3.54,5.41ZM32.36,5.83,30.15,2.5A15.82,15.82,0,0,1,36.49,0l.63,4A11.6,11.6,0,0,0,32.36,5.83Zm13.9,0A11.66,11.66,0,0,0,41.5,4L42.1,0a15.84,15.84,0,0,1,6.36,2.44Z"/><path class="cls-2" d="M78.69,70.31a11,11,0,0,1-3.6-.59l1.3-3.78a7.25,7.25,0,0,0,2.3.37Z"/></g></g></svg>
|
After Width: | Height: | Size: 2.6 KiB |
18
public/app/plugins/panel/histogram/models.cue
Normal file
18
public/app/plugins/panel/histogram/models.cue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
package grafanaschema
|
||||||
|
|
||||||
|
Family: {
|
||||||
|
lineages: [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
PanelOptions: {
|
||||||
|
bucketSize?: int
|
||||||
|
bucketOffset: int | *0
|
||||||
|
combine?: bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: FieldConfig
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
migrations: []
|
||||||
|
}
|
36
public/app/plugins/panel/histogram/models.gen.ts
Normal file
36
public/app/plugins/panel/histogram/models.gen.ts
Normal file
@ -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,
|
||||||
|
};
|
92
public/app/plugins/panel/histogram/module.tsx
Normal file
92
public/app/plugins/panel/histogram/module.tsx
Normal file
@ -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<PanelOptions, PanelFieldConfig>(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,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
18
public/app/plugins/panel/histogram/plugin.json
Normal file
18
public/app/plugins/panel/histogram/plugin.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
30
public/app/plugins/panel/histogram/utils.ts
Normal file
30
public/app/plugins/panel/histogram/utils.ts
Normal file
@ -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;
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user