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 { RenameByRegexTransformerOptions } from './transformers/renameByRegex';
|
||||
export { outerJoinDataFrames } from './transformers/joinDataFrames';
|
||||
export * from './transformers/histogram';
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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',
|
||||
groupBy = 'groupBy',
|
||||
sortBy = 'sortBy',
|
||||
histogram = 'histogram',
|
||||
}
|
||||
|
@ -216,7 +216,7 @@ export function outerJoinDataFrames(options: JoinOptions): DataFrame | undefined
|
||||
//--------------------------------------------------------------------------------
|
||||
|
||||
// Copied from uplot
|
||||
type AlignedData = [number[], ...Array<Array<number | null>>];
|
||||
export type AlignedData = [number[], ...Array<Array<number | null>>];
|
||||
|
||||
// nullModes
|
||||
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
|
||||
function join(tables: AlignedData[], nullModes: number[][]) {
|
||||
export function join(tables: AlignedData[], nullModes?: number[][]) {
|
||||
const xVals = new Set<number>();
|
||||
|
||||
for (let ti = 0; ti < tables.length; ti++) {
|
||||
|
@ -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;
|
||||
|
@ -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 { concatenateTransformRegistryItem } from '../components/TransformersUI/ConcatenateTransformerEditor';
|
||||
import { renameByRegexTransformRegistryItem } from '../components/TransformersUI/RenameByRegexTransformer';
|
||||
import { histogramTransformRegistryItem } from '../components/TransformersUI/HistogramTransformerEditor';
|
||||
|
||||
export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> => {
|
||||
return [
|
||||
@ -30,5 +31,6 @@ export const getStandardTransformers = (): Array<TransformerRegistryItem<any>> =
|
||||
groupByTransformRegistryItem,
|
||||
sortByTransformRegistryItem,
|
||||
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 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;
|
||||
|
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