mirror of
https://github.com/prometheus/prometheus.git
synced 2025-03-05 20:59:13 -08:00
ui: heatmap visualization for histogram buckets (#13096)
ui: heatmap visualization for histogram buckets Signed-off-by: Yury Moladau <yurymolodov@gmail.com> --------- Signed-off-by: Yury Moladau <yurymolodov@gmail.com>
This commit is contained in:
parent
eda73dd3e5
commit
2e205ee95c
|
@ -4,6 +4,7 @@ import { shallow, mount } from 'enzyme';
|
||||||
import Graph from './Graph';
|
import Graph from './Graph';
|
||||||
import ReactResizeDetector from 'react-resize-detector';
|
import ReactResizeDetector from 'react-resize-detector';
|
||||||
import { Legend } from './Legend';
|
import { Legend } from './Legend';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
describe('Graph', () => {
|
describe('Graph', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
@ -30,7 +31,7 @@ describe('Graph', () => {
|
||||||
endTime: 1572130692,
|
endTime: 1572130692,
|
||||||
resolution: 28,
|
resolution: 28,
|
||||||
},
|
},
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
data: {
|
data: {
|
||||||
resultType: 'matrix',
|
resultType: 'matrix',
|
||||||
result: [
|
result: [
|
||||||
|
@ -115,7 +116,7 @@ describe('Graph', () => {
|
||||||
graph = mount(
|
graph = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
@ -152,7 +153,7 @@ describe('Graph', () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('should trigger state update when stacked prop is changed', () => {
|
it('should trigger state update when stacked prop is changed', () => {
|
||||||
graph.setProps({ stacked: false });
|
graph.setProps({ displayMode: GraphDisplayMode.Lines });
|
||||||
expect(spyState).toHaveBeenCalledWith(
|
expect(spyState).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
chartData: {
|
chartData: {
|
||||||
|
@ -177,7 +178,7 @@ describe('Graph', () => {
|
||||||
const graph = mount(
|
const graph = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572130692,
|
endTime: 1572130692,
|
||||||
|
@ -201,7 +202,7 @@ describe('Graph', () => {
|
||||||
const graph = shallow(
|
const graph = shallow(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
@ -221,7 +222,7 @@ describe('Graph', () => {
|
||||||
const graph = mount(
|
const graph = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
@ -240,7 +241,7 @@ describe('Graph', () => {
|
||||||
const graph = mount(
|
const graph = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
@ -261,7 +262,7 @@ describe('Graph', () => {
|
||||||
const graph = mount(
|
const graph = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
@ -289,7 +290,7 @@ describe('Graph', () => {
|
||||||
const graph: any = mount(
|
const graph: any = mount(
|
||||||
<Graph
|
<Graph
|
||||||
{...({
|
{...({
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572128598,
|
endTime: 1572128598,
|
||||||
|
|
|
@ -3,18 +3,20 @@ import React, { PureComponent } from 'react';
|
||||||
import ReactResizeDetector from 'react-resize-detector';
|
import ReactResizeDetector from 'react-resize-detector';
|
||||||
|
|
||||||
import { Legend } from './Legend';
|
import { Legend } from './Legend';
|
||||||
import { Metric, Histogram, ExemplarData, QueryParams } from '../../types/types';
|
import { ExemplarData, Histogram, Metric, QueryParams } from '../../types/types';
|
||||||
import { isPresent } from '../../utils';
|
import { isPresent } from '../../utils';
|
||||||
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
import { getOptions, normalizeData, toHoverColor } from './GraphHelpers';
|
||||||
import { Button } from 'reactstrap';
|
import { Button } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
require('../../vendor/flot/jquery.flot');
|
require('../../vendor/flot/jquery.flot');
|
||||||
require('../../vendor/flot/jquery.flot.stack');
|
require('../../vendor/flot/jquery.flot.stack');
|
||||||
require('../../vendor/flot/jquery.flot.time');
|
require('../../vendor/flot/jquery.flot.time');
|
||||||
require('../../vendor/flot/jquery.flot.crosshair');
|
require('../../vendor/flot/jquery.flot.crosshair');
|
||||||
require('../../vendor/flot/jquery.flot.selection');
|
require('../../vendor/flot/jquery.flot.selection');
|
||||||
|
require('../../vendor/flot/jquery.flot.heatmap');
|
||||||
require('jquery.flot.tooltip');
|
require('jquery.flot.tooltip');
|
||||||
|
|
||||||
export interface GraphProps {
|
export interface GraphProps {
|
||||||
|
@ -23,7 +25,7 @@ export interface GraphProps {
|
||||||
result: Array<{ metric: Metric; values?: [number, string][]; histograms?: [number, Histogram][] }>;
|
result: Array<{ metric: Metric; values?: [number, string][]; histograms?: [number, Histogram][] }>;
|
||||||
};
|
};
|
||||||
exemplars: ExemplarData;
|
exemplars: ExemplarData;
|
||||||
stacked: boolean;
|
displayMode: GraphDisplayMode;
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
showExemplars: boolean;
|
showExemplars: boolean;
|
||||||
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
||||||
|
@ -69,11 +71,11 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidUpdate(prevProps: GraphProps): void {
|
componentDidUpdate(prevProps: GraphProps): void {
|
||||||
const { data, stacked, useLocalTime, showExemplars } = this.props;
|
const { data, displayMode, useLocalTime, showExemplars } = this.props;
|
||||||
if (prevProps.data !== data) {
|
if (prevProps.data !== data) {
|
||||||
this.selectedSeriesIndexes = [];
|
this.selectedSeriesIndexes = [];
|
||||||
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
||||||
} else if (prevProps.stacked !== stacked) {
|
} else if (prevProps.displayMode !== displayMode) {
|
||||||
this.setState({ chartData: normalizeData(this.props) }, () => {
|
this.setState({ chartData: normalizeData(this.props) }, () => {
|
||||||
if (this.selectedSeriesIndexes.length === 0) {
|
if (this.selectedSeriesIndexes.length === 0) {
|
||||||
this.plot();
|
this.plot();
|
||||||
|
@ -143,7 +145,18 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
}
|
}
|
||||||
this.destroyPlot();
|
this.destroyPlot();
|
||||||
|
|
||||||
this.$chart = $.plot($(this.chartRef.current), data, getOptions(this.props.stacked, this.props.useLocalTime));
|
const options = getOptions(this.props.displayMode === GraphDisplayMode.Stacked, this.props.useLocalTime);
|
||||||
|
const isHeatmap = this.props.displayMode === GraphDisplayMode.Heatmap;
|
||||||
|
options.series.heatmap = isHeatmap;
|
||||||
|
|
||||||
|
if (options.yaxis && isHeatmap) {
|
||||||
|
options.yaxis.ticks = () => new Array(data.length + 1).fill(0).map((_el, i) => i);
|
||||||
|
options.yaxis.tickFormatter = (val) => `${val ? data[val - 1].labels.le : ''}`;
|
||||||
|
options.yaxis.min = 0;
|
||||||
|
options.yaxis.max = data.length;
|
||||||
|
options.series.lines = { show: false };
|
||||||
|
}
|
||||||
|
this.$chart = $.plot($(this.chartRef.current), data, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
destroyPlot = (): void => {
|
destroyPlot = (): void => {
|
||||||
|
@ -165,7 +178,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
const { chartData } = this.state;
|
const { chartData } = this.state;
|
||||||
this.plot(
|
this.plot(
|
||||||
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
|
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
|
||||||
? [...chartData.series.map(toHoverColor(selectedIndex, this.props.stacked)), ...chartData.exemplars]
|
? [
|
||||||
|
...chartData.series.map(toHoverColor(selectedIndex, this.props.displayMode === GraphDisplayMode.Stacked)),
|
||||||
|
...chartData.exemplars,
|
||||||
|
]
|
||||||
: [
|
: [
|
||||||
...chartData.series.filter((_, i) => selected.includes(i)),
|
...chartData.series.filter((_, i) => selected.includes(i)),
|
||||||
...chartData.exemplars.filter((exemplar) => {
|
...chartData.exemplars.filter((exemplar) => {
|
||||||
|
@ -190,7 +206,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
}
|
}
|
||||||
this.rafID = requestAnimationFrame(() => {
|
this.rafID = requestAnimationFrame(() => {
|
||||||
this.plotSetAndDraw([
|
this.plotSetAndDraw([
|
||||||
...this.state.chartData.series.map(toHoverColor(index, this.props.stacked)),
|
...this.state.chartData.series.map(toHoverColor(index, this.props.displayMode === GraphDisplayMode.Stacked)),
|
||||||
...this.state.chartData.exemplars,
|
...this.state.chartData.exemplars,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
@ -251,6 +267,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
{this.props.displayMode !== GraphDisplayMode.Heatmap && (
|
||||||
<Legend
|
<Legend
|
||||||
shouldReset={this.selectedSeriesIndexes.length === 0}
|
shouldReset={this.selectedSeriesIndexes.length === 0}
|
||||||
chartData={chartData.series}
|
chartData={chartData.series}
|
||||||
|
@ -258,6 +275,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
||||||
onLegendMouseOut={this.handleLegendMouseOut}
|
onLegendMouseOut={this.handleLegendMouseOut}
|
||||||
onSeriesToggle={this.handleSeriesSelect}
|
onSeriesToggle={this.handleSeriesSelect}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
|
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
|
||||||
<br style={{ clear: 'both' }} />
|
<br style={{ clear: 'both' }} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -5,13 +5,15 @@ import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'r
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
const defaultGraphControlProps = {
|
const defaultGraphControlProps = {
|
||||||
range: 60 * 60 * 24 * 1000,
|
range: 60 * 60 * 24 * 1000,
|
||||||
endTime: 1572100217898,
|
endTime: 1572100217898,
|
||||||
useLocalTime: false,
|
useLocalTime: false,
|
||||||
resolution: 10,
|
resolution: 10,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
|
isHeatmapData: false,
|
||||||
showExemplars: false,
|
showExemplars: false,
|
||||||
|
|
||||||
onChangeRange: (): void => {
|
onChangeRange: (): void => {
|
||||||
|
@ -29,6 +31,9 @@ const defaultGraphControlProps = {
|
||||||
onChangeShowExemplars: (): void => {
|
onChangeShowExemplars: (): void => {
|
||||||
// Do nothing.
|
// Do nothing.
|
||||||
},
|
},
|
||||||
|
onChangeDisplayMode: (): void => {
|
||||||
|
// Do nothing.
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('GraphControls', () => {
|
describe('GraphControls', () => {
|
||||||
|
@ -163,10 +168,10 @@ describe('GraphControls', () => {
|
||||||
},
|
},
|
||||||
].forEach((testCase) => {
|
].forEach((testCase) => {
|
||||||
const results: boolean[] = [];
|
const results: boolean[] = [];
|
||||||
const onChange = (stacked: boolean): void => {
|
const onChange = (mode: GraphDisplayMode): void => {
|
||||||
results.push(stacked);
|
results.push(mode === GraphDisplayMode.Stacked);
|
||||||
};
|
};
|
||||||
const controls = shallow(<GraphControls {...defaultGraphControlProps} onChangeStacking={onChange} />);
|
const controls = shallow(<GraphControls {...defaultGraphControlProps} onChangeDisplayMode={onChange} />);
|
||||||
const group = controls.find(ButtonGroup);
|
const group = controls.find(ButtonGroup);
|
||||||
const btn = group.find(Button).filterWhere((btn) => btn.prop('title') === testCase.title);
|
const btn = group.find(Button).filterWhere((btn) => btn.prop('title') === testCase.title);
|
||||||
const onClick = btn.prop('onClick');
|
const onClick = btn.prop('onClick');
|
||||||
|
|
|
@ -2,23 +2,24 @@ import React, { Component } from 'react';
|
||||||
import { Button, ButtonGroup, Form, Input, InputGroup, InputGroupAddon } from 'reactstrap';
|
import { Button, ButtonGroup, Form, Input, InputGroup, InputGroupAddon } from 'reactstrap';
|
||||||
|
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faChartArea, faChartLine, faMinus, faPlus } from '@fortawesome/free-solid-svg-icons';
|
import { faChartArea, faChartLine, faMinus, faPlus, faBarChart } from '@fortawesome/free-solid-svg-icons';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
import { formatDuration, parseDuration } from '../../utils';
|
import { formatDuration, parseDuration } from '../../utils';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
interface GraphControlsProps {
|
interface GraphControlsProps {
|
||||||
range: number;
|
range: number;
|
||||||
endTime: number | null;
|
endTime: number | null;
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
resolution: number | null;
|
resolution: number | null;
|
||||||
stacked: boolean;
|
displayMode: GraphDisplayMode;
|
||||||
|
isHeatmapData: boolean;
|
||||||
showExemplars: boolean;
|
showExemplars: boolean;
|
||||||
|
|
||||||
onChangeRange: (range: number) => void;
|
onChangeRange: (range: number) => void;
|
||||||
onChangeEndTime: (endTime: number | null) => void;
|
onChangeEndTime: (endTime: number | null) => void;
|
||||||
onChangeResolution: (resolution: number | null) => void;
|
onChangeResolution: (resolution: number | null) => void;
|
||||||
onChangeStacking: (stacked: boolean) => void;
|
|
||||||
onChangeShowExemplars: (show: boolean) => void;
|
onChangeShowExemplars: (show: boolean) => void;
|
||||||
|
onChangeDisplayMode: (mode: GraphDisplayMode) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
class GraphControls extends Component<GraphControlsProps> {
|
class GraphControls extends Component<GraphControlsProps> {
|
||||||
|
@ -153,14 +154,29 @@ class GraphControls extends Component<GraphControlsProps> {
|
||||||
<ButtonGroup className="stacked-input" size="sm">
|
<ButtonGroup className="stacked-input" size="sm">
|
||||||
<Button
|
<Button
|
||||||
title="Show unstacked line graph"
|
title="Show unstacked line graph"
|
||||||
onClick={() => this.props.onChangeStacking(false)}
|
onClick={() => this.props.onChangeDisplayMode(GraphDisplayMode.Lines)}
|
||||||
active={!this.props.stacked}
|
active={this.props.displayMode === GraphDisplayMode.Lines}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faChartLine} fixedWidth />
|
<FontAwesomeIcon icon={faChartLine} fixedWidth />
|
||||||
</Button>
|
</Button>
|
||||||
<Button title="Show stacked graph" onClick={() => this.props.onChangeStacking(true)} active={this.props.stacked}>
|
<Button
|
||||||
|
title="Show stacked graph"
|
||||||
|
onClick={() => this.props.onChangeDisplayMode(GraphDisplayMode.Stacked)}
|
||||||
|
active={this.props.displayMode === GraphDisplayMode.Stacked}
|
||||||
|
>
|
||||||
<FontAwesomeIcon icon={faChartArea} fixedWidth />
|
<FontAwesomeIcon icon={faChartArea} fixedWidth />
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* TODO: Consider replacing this button with a select dropdown in the future,
|
||||||
|
to allow users to choose from multiple histogram series if available. */}
|
||||||
|
{this.props.isHeatmapData && (
|
||||||
|
<Button
|
||||||
|
title="Show heatmap graph"
|
||||||
|
onClick={() => this.props.onChangeDisplayMode(GraphDisplayMode.Heatmap)}
|
||||||
|
active={this.props.displayMode === GraphDisplayMode.Heatmap}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faBarChart} fixedWidth />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</ButtonGroup>
|
</ButtonGroup>
|
||||||
|
|
||||||
<ButtonGroup className="show-exemplars" size="sm">
|
<ButtonGroup className="show-exemplars" size="sm">
|
||||||
|
|
56
web/ui/react-app/src/pages/graph/GraphHeatmapHelpers.ts
Normal file
56
web/ui/react-app/src/pages/graph/GraphHeatmapHelpers.ts
Normal file
|
@ -0,0 +1,56 @@
|
||||||
|
import { GraphProps, GraphSeries } from './Graph';
|
||||||
|
|
||||||
|
export function isHeatmapData(data: GraphProps['data']) {
|
||||||
|
if (!data?.result?.length || data?.result?.length < 2) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = data.result;
|
||||||
|
const firstLabels = Object.keys(result[0].metric).filter((label) => label !== 'le');
|
||||||
|
return result.every(({ metric }) => {
|
||||||
|
const labels = Object.keys(metric).filter((label) => label !== 'le');
|
||||||
|
const allLabelsMatch = labels.every((label) => metric[label] === result[0].metric[label]);
|
||||||
|
return metric.le && labels.length === firstLabels.length && allLabelsMatch;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareHeatmapData(buckets: GraphSeries[]) {
|
||||||
|
if (!buckets.every((a) => a.labels.le)) {
|
||||||
|
return buckets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedBuckets = buckets.sort((a, b) => promValueToNumber(a.labels.le) - promValueToNumber(b.labels.le));
|
||||||
|
const result: GraphSeries[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < sortedBuckets.length; i++) {
|
||||||
|
const values = [];
|
||||||
|
const { data, labels, color } = sortedBuckets[i];
|
||||||
|
|
||||||
|
for (const [timestamp, value] of data) {
|
||||||
|
const prevVal = sortedBuckets[i - 1]?.data.find((v) => v[0] === timestamp)?.[1] || 0;
|
||||||
|
const newVal = Number(value) - prevVal;
|
||||||
|
values.push([Number(timestamp), newVal]);
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
data: values,
|
||||||
|
labels,
|
||||||
|
color,
|
||||||
|
index: i,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promValueToNumber(s: string) {
|
||||||
|
switch (s) {
|
||||||
|
case 'NaN':
|
||||||
|
return NaN;
|
||||||
|
case 'Inf':
|
||||||
|
case '+Inf':
|
||||||
|
return Infinity;
|
||||||
|
case '-Inf':
|
||||||
|
return -Infinity;
|
||||||
|
default:
|
||||||
|
return parseFloat(s);
|
||||||
|
}
|
||||||
|
}
|
|
@ -212,6 +212,7 @@ describe('GraphHelpers', () => {
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
stack: false,
|
stack: false,
|
||||||
|
heatmap: false,
|
||||||
lines: { lineWidth: 1, steps: false, fill: true },
|
lines: { lineWidth: 1, steps: false, fill: true },
|
||||||
shadowSize: 0,
|
shadowSize: 0,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import $ from 'jquery';
|
import $ from 'jquery';
|
||||||
|
|
||||||
import { escapeHTML } from '../../utils';
|
import { escapeHTML } from '../../utils';
|
||||||
import { GraphProps, GraphData, GraphSeries, GraphExemplar } from './Graph';
|
import { GraphData, GraphExemplar, GraphProps, GraphSeries } from './Graph';
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
import { colorPool } from './ColorPool';
|
import { colorPool } from './ColorPool';
|
||||||
|
import { prepareHeatmapData } from './GraphHeatmapHelpers';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
export const formatValue = (y: number | null): string => {
|
export const formatValue = (y: number | null): string => {
|
||||||
if (y === null) {
|
if (y === null) {
|
||||||
|
@ -145,6 +147,7 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
||||||
},
|
},
|
||||||
series: {
|
series: {
|
||||||
stack: false, // Stacking is set on a per-series basis because exemplar symbols don't support it.
|
stack: false, // Stacking is set on a per-series basis because exemplar symbols don't support it.
|
||||||
|
heatmap: false,
|
||||||
lines: {
|
lines: {
|
||||||
lineWidth: stacked ? 1 : 2,
|
lineWidth: stacked ? 1 : 2,
|
||||||
steps: false,
|
steps: false,
|
||||||
|
@ -158,7 +161,7 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const normalizeData = ({ queryParams, data, exemplars, stacked }: GraphProps): GraphData => {
|
export const normalizeData = ({ queryParams, data, exemplars, displayMode }: GraphProps): GraphData => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const { startTime, endTime, resolution } = queryParams!;
|
const { startTime, endTime, resolution } = queryParams!;
|
||||||
|
|
||||||
|
@ -188,8 +191,7 @@ export const normalizeData = ({ queryParams, data, exemplars, stacked }: GraphPr
|
||||||
}
|
}
|
||||||
const deviation = stdDeviation(sum, values);
|
const deviation = stdDeviation(sum, values);
|
||||||
|
|
||||||
return {
|
const series = data.result.map(({ values, histograms, metric }, index) => {
|
||||||
series: data.result.map(({ values, histograms, metric }, index) => {
|
|
||||||
// Insert nulls for all missing steps.
|
// Insert nulls for all missing steps.
|
||||||
const data = [];
|
const data = [];
|
||||||
let valuePos = 0;
|
let valuePos = 0;
|
||||||
|
@ -209,15 +211,17 @@ export const normalizeData = ({ queryParams, data, exemplars, stacked }: GraphPr
|
||||||
data.push([t * 1000, null]);
|
data.push([t * 1000, null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: metric !== null ? metric : {},
|
labels: metric !== null ? metric : {},
|
||||||
color: colorPool[index % colorPool.length],
|
color: colorPool[index % colorPool.length],
|
||||||
stack: stacked,
|
stack: displayMode === GraphDisplayMode.Stacked,
|
||||||
data,
|
data,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
series: displayMode === GraphDisplayMode.Heatmap ? prepareHeatmapData(series) : series,
|
||||||
exemplars: Object.values(buckets).flatMap((bucket) => {
|
exemplars: Object.values(buckets).flatMap((bucket) => {
|
||||||
if (bucket.length === 1) {
|
if (bucket.length === 1) {
|
||||||
return bucket[0];
|
return bucket[0];
|
||||||
|
|
|
@ -3,12 +3,13 @@ import { Alert } from 'reactstrap';
|
||||||
import Graph from './Graph';
|
import Graph from './Graph';
|
||||||
import { QueryParams, ExemplarData } from '../../types/types';
|
import { QueryParams, ExemplarData } from '../../types/types';
|
||||||
import { isPresent } from '../../utils';
|
import { isPresent } from '../../utils';
|
||||||
|
import { GraphDisplayMode } from './Panel';
|
||||||
|
|
||||||
interface GraphTabContentProps {
|
interface GraphTabContentProps {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
data: any;
|
data: any;
|
||||||
exemplars: ExemplarData;
|
exemplars: ExemplarData;
|
||||||
stacked: boolean;
|
displayMode: GraphDisplayMode;
|
||||||
useLocalTime: boolean;
|
useLocalTime: boolean;
|
||||||
showExemplars: boolean;
|
showExemplars: boolean;
|
||||||
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
||||||
|
@ -19,7 +20,7 @@ interface GraphTabContentProps {
|
||||||
export const GraphTabContent: FC<GraphTabContentProps> = ({
|
export const GraphTabContent: FC<GraphTabContentProps> = ({
|
||||||
data,
|
data,
|
||||||
exemplars,
|
exemplars,
|
||||||
stacked,
|
displayMode,
|
||||||
useLocalTime,
|
useLocalTime,
|
||||||
lastQueryParams,
|
lastQueryParams,
|
||||||
showExemplars,
|
showExemplars,
|
||||||
|
@ -41,7 +42,7 @@ export const GraphTabContent: FC<GraphTabContentProps> = ({
|
||||||
<Graph
|
<Graph
|
||||||
data={data}
|
data={data}
|
||||||
exemplars={exemplars}
|
exemplars={exemplars}
|
||||||
stacked={stacked}
|
displayMode={displayMode}
|
||||||
useLocalTime={useLocalTime}
|
useLocalTime={useLocalTime}
|
||||||
showExemplars={showExemplars}
|
showExemplars={showExemplars}
|
||||||
handleTimeRangeSelection={handleTimeRangeSelection}
|
handleTimeRangeSelection={handleTimeRangeSelection}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { mount, shallow } from 'enzyme';
|
import { mount, shallow } from 'enzyme';
|
||||||
import Panel, { PanelOptions, PanelType } from './Panel';
|
import Panel, { GraphDisplayMode, PanelOptions, PanelType } from './Panel';
|
||||||
import GraphControls from './GraphControls';
|
import GraphControls from './GraphControls';
|
||||||
import { NavLink, TabPane } from 'reactstrap';
|
import { NavLink, TabPane } from 'reactstrap';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
|
@ -14,7 +14,7 @@ const defaultProps = {
|
||||||
range: 10,
|
range: 10,
|
||||||
endTime: 1572100217898,
|
endTime: 1572100217898,
|
||||||
resolution: 28,
|
resolution: 28,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
showExemplars: true,
|
showExemplars: true,
|
||||||
},
|
},
|
||||||
onOptionsChanged: (): void => {
|
onOptionsChanged: (): void => {
|
||||||
|
@ -84,7 +84,7 @@ describe('Panel', () => {
|
||||||
range: 10,
|
range: 10,
|
||||||
endTime: 1572100217898,
|
endTime: 1572100217898,
|
||||||
resolution: 28,
|
resolution: 28,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
showExemplars: true,
|
showExemplars: true,
|
||||||
};
|
};
|
||||||
const graphPanel = mount(<Panel {...defaultProps} options={options} />);
|
const graphPanel = mount(<Panel {...defaultProps} options={options} />);
|
||||||
|
@ -94,8 +94,8 @@ describe('Panel', () => {
|
||||||
expect(controls.prop('endTime')).toEqual(options.endTime);
|
expect(controls.prop('endTime')).toEqual(options.endTime);
|
||||||
expect(controls.prop('range')).toEqual(options.range);
|
expect(controls.prop('range')).toEqual(options.range);
|
||||||
expect(controls.prop('resolution')).toEqual(options.resolution);
|
expect(controls.prop('resolution')).toEqual(options.resolution);
|
||||||
expect(controls.prop('stacked')).toEqual(options.stacked);
|
expect(controls.prop('displayMode')).toEqual(options.displayMode);
|
||||||
expect(graph.prop('stacked')).toEqual(options.stacked);
|
expect(graph.prop('displayMode')).toEqual(options.displayMode);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when switching between modes', () => {
|
describe('when switching between modes', () => {
|
||||||
|
|
|
@ -13,6 +13,7 @@ import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||||
import { QueryParams, ExemplarData } from '../../types/types';
|
import { QueryParams, ExemplarData } from '../../types/types';
|
||||||
import { API_PATH } from '../../constants/constants';
|
import { API_PATH } from '../../constants/constants';
|
||||||
import { debounce } from '../../utils';
|
import { debounce } from '../../utils';
|
||||||
|
import { isHeatmapData } from './GraphHeatmapHelpers';
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
options: PanelOptions;
|
options: PanelOptions;
|
||||||
|
@ -39,6 +40,7 @@ interface PanelState {
|
||||||
error: string | null;
|
error: string | null;
|
||||||
stats: QueryStats | null;
|
stats: QueryStats | null;
|
||||||
exprInputValue: string;
|
exprInputValue: string;
|
||||||
|
isHeatmapData: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelOptions {
|
export interface PanelOptions {
|
||||||
|
@ -47,7 +49,7 @@ export interface PanelOptions {
|
||||||
range: number; // Range in milliseconds.
|
range: number; // Range in milliseconds.
|
||||||
endTime: number | null; // Timestamp in milliseconds.
|
endTime: number | null; // Timestamp in milliseconds.
|
||||||
resolution: number | null; // Resolution in seconds.
|
resolution: number | null; // Resolution in seconds.
|
||||||
stacked: boolean;
|
displayMode: GraphDisplayMode;
|
||||||
showExemplars: boolean;
|
showExemplars: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,13 +58,19 @@ export enum PanelType {
|
||||||
Table = 'table',
|
Table = 'table',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum GraphDisplayMode {
|
||||||
|
Lines = 'lines',
|
||||||
|
Stacked = 'stacked',
|
||||||
|
Heatmap = 'heatmap',
|
||||||
|
}
|
||||||
|
|
||||||
export const PanelDefaultOptions: PanelOptions = {
|
export const PanelDefaultOptions: PanelOptions = {
|
||||||
type: PanelType.Table,
|
type: PanelType.Table,
|
||||||
expr: '',
|
expr: '',
|
||||||
range: 60 * 60 * 1000,
|
range: 60 * 60 * 1000,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
resolution: null,
|
resolution: null,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
showExemplars: false,
|
showExemplars: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -82,6 +90,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
error: null,
|
error: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
exprInputValue: props.options.expr,
|
exprInputValue: props.options.expr,
|
||||||
|
isHeatmapData: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.debounceExecuteQuery = debounce(this.executeQuery.bind(this), 250);
|
this.debounceExecuteQuery = debounce(this.executeQuery.bind(this), 250);
|
||||||
|
@ -184,6 +193,11 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isHeatmap = isHeatmapData(query.data);
|
||||||
|
if (!isHeatmap) {
|
||||||
|
this.setOptions({ displayMode: GraphDisplayMode.Lines });
|
||||||
|
}
|
||||||
|
|
||||||
this.setState({
|
this.setState({
|
||||||
error: null,
|
error: null,
|
||||||
data: query.data,
|
data: query.data,
|
||||||
|
@ -200,6 +214,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
resultSeries,
|
resultSeries,
|
||||||
},
|
},
|
||||||
loading: false,
|
loading: false,
|
||||||
|
isHeatmapData: isHeatmap,
|
||||||
});
|
});
|
||||||
this.abortInFlightFetch = null;
|
this.abortInFlightFetch = null;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
|
@ -252,8 +267,8 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
this.setOptions({ type: type });
|
this.setOptions({ type: type });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChangeStacking = (stacked: boolean): void => {
|
handleChangeDisplayMode = (mode: GraphDisplayMode): void => {
|
||||||
this.setOptions({ stacked: stacked });
|
this.setOptions({ displayMode: mode });
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChangeShowExemplars = (show: boolean): void => {
|
handleChangeShowExemplars = (show: boolean): void => {
|
||||||
|
@ -337,18 +352,19 @@ class Panel extends Component<PanelProps, PanelState> {
|
||||||
endTime={options.endTime}
|
endTime={options.endTime}
|
||||||
useLocalTime={this.props.useLocalTime}
|
useLocalTime={this.props.useLocalTime}
|
||||||
resolution={options.resolution}
|
resolution={options.resolution}
|
||||||
stacked={options.stacked}
|
displayMode={options.displayMode}
|
||||||
|
isHeatmapData={this.state.isHeatmapData}
|
||||||
showExemplars={options.showExemplars}
|
showExemplars={options.showExemplars}
|
||||||
onChangeRange={this.handleChangeRange}
|
onChangeRange={this.handleChangeRange}
|
||||||
onChangeEndTime={this.handleChangeEndTime}
|
onChangeEndTime={this.handleChangeEndTime}
|
||||||
onChangeResolution={this.handleChangeResolution}
|
onChangeResolution={this.handleChangeResolution}
|
||||||
onChangeStacking={this.handleChangeStacking}
|
onChangeDisplayMode={this.handleChangeDisplayMode}
|
||||||
onChangeShowExemplars={this.handleChangeShowExemplars}
|
onChangeShowExemplars={this.handleChangeShowExemplars}
|
||||||
/>
|
/>
|
||||||
<GraphTabContent
|
<GraphTabContent
|
||||||
data={this.state.data}
|
data={this.state.data}
|
||||||
exemplars={this.state.exemplars}
|
exemplars={this.state.exemplars}
|
||||||
stacked={options.stacked}
|
displayMode={options.displayMode}
|
||||||
useLocalTime={this.props.useLocalTime}
|
useLocalTime={this.props.useLocalTime}
|
||||||
showExemplars={options.showExemplars}
|
showExemplars={options.showExemplars}
|
||||||
lastQueryParams={this.state.lastQueryParams}
|
lastQueryParams={this.state.lastQueryParams}
|
||||||
|
|
1
web/ui/react-app/src/types/index.d.ts
vendored
1
web/ui/react-app/src/types/index.d.ts
vendored
|
@ -40,6 +40,7 @@ declare namespace jquery.flot {
|
||||||
};
|
};
|
||||||
series: { [K in keyof jquery.flot.seriesOptions]: jq.flot.seriesOptions[K] } & {
|
series: { [K in keyof jquery.flot.seriesOptions]: jq.flot.seriesOptions[K] } & {
|
||||||
stack: boolean;
|
stack: boolean;
|
||||||
|
heatmap: boolean;
|
||||||
};
|
};
|
||||||
selection: {
|
selection: {
|
||||||
mode: string;
|
mode: string;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import moment from 'moment-timezone';
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
import { PanelOptions, PanelType, PanelDefaultOptions } from '../pages/graph/Panel';
|
import { GraphDisplayMode, PanelDefaultOptions, PanelOptions, PanelType } from '../pages/graph/Panel';
|
||||||
import { PanelMeta } from '../pages/graph/PanelList';
|
import { PanelMeta } from '../pages/graph/PanelList';
|
||||||
|
|
||||||
export const generateID = (): string => {
|
export const generateID = (): string => {
|
||||||
|
@ -196,8 +196,12 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
|
||||||
case 'tab':
|
case 'tab':
|
||||||
return { type: decodedValue === '0' ? PanelType.Graph : PanelType.Table };
|
return { type: decodedValue === '0' ? PanelType.Graph : PanelType.Table };
|
||||||
|
|
||||||
|
case 'display_mode':
|
||||||
|
const validKey = Object.values(GraphDisplayMode).includes(decodedValue as GraphDisplayMode);
|
||||||
|
return { displayMode: validKey ? (decodedValue as GraphDisplayMode) : GraphDisplayMode.Lines };
|
||||||
|
|
||||||
case 'stacked':
|
case 'stacked':
|
||||||
return { stacked: decodedValue === '1' };
|
return { displayMode: decodedValue === '1' ? GraphDisplayMode.Stacked : GraphDisplayMode.Lines };
|
||||||
|
|
||||||
case 'show_exemplars':
|
case 'show_exemplars':
|
||||||
return { showExemplars: decodedValue === '1' };
|
return { showExemplars: decodedValue === '1' };
|
||||||
|
@ -225,12 +229,12 @@ export const formatParam =
|
||||||
|
|
||||||
export const toQueryString = ({ key, options }: PanelMeta): string => {
|
export const toQueryString = ({ key, options }: PanelMeta): string => {
|
||||||
const formatWithKey = formatParam(key);
|
const formatWithKey = formatParam(key);
|
||||||
const { expr, type, stacked, range, endTime, resolution, showExemplars } = options;
|
const { expr, type, displayMode, range, endTime, resolution, showExemplars } = options;
|
||||||
const time = isPresent(endTime) ? formatTime(endTime) : false;
|
const time = isPresent(endTime) ? formatTime(endTime) : false;
|
||||||
const urlParams = [
|
const urlParams = [
|
||||||
formatWithKey('expr', expr),
|
formatWithKey('expr', expr),
|
||||||
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
||||||
formatWithKey('stacked', stacked ? 1 : 0),
|
formatWithKey('display_mode', displayMode),
|
||||||
formatWithKey('show_exemplars', showExemplars ? 1 : 0),
|
formatWithKey('show_exemplars', showExemplars ? 1 : 0),
|
||||||
formatWithKey('range_input', formatDuration(range)),
|
formatWithKey('range_input', formatDuration(range)),
|
||||||
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
|
time ? `${formatWithKey('end_input', time)}&${formatWithKey('moment_input', time)}` : '',
|
||||||
|
@ -264,7 +268,9 @@ export const getQueryParam = (key: string): string => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createExpressionLink = (expr: string): string => {
|
export const createExpressionLink = (expr: string): string => {
|
||||||
return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.stacked=0&g0.show_exemplars=0.g0.range_input=1h.`;
|
return `../graph?g0.expr=${encodeURIComponent(expr)}&g0.tab=1&g0.display_mode=${
|
||||||
|
GraphDisplayMode.Lines
|
||||||
|
}&g0.show_exemplars=0.g0.range_input=1h.`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any,
|
||||||
|
|
|
@ -16,7 +16,7 @@ import {
|
||||||
decodePanelOptionsFromQueryString,
|
decodePanelOptionsFromQueryString,
|
||||||
parsePrometheusFloat,
|
parsePrometheusFloat,
|
||||||
} from '.';
|
} from '.';
|
||||||
import { PanelType } from '../pages/graph/Panel';
|
import { GraphDisplayMode, PanelType } from '../pages/graph/Panel';
|
||||||
|
|
||||||
describe('Utils', () => {
|
describe('Utils', () => {
|
||||||
describe('escapeHTML', (): void => {
|
describe('escapeHTML', (): void => {
|
||||||
|
@ -210,7 +210,7 @@ describe('Utils', () => {
|
||||||
expr: 'rate(node_cpu_seconds_total{mode="system"}[1m])',
|
expr: 'rate(node_cpu_seconds_total{mode="system"}[1m])',
|
||||||
range: 60 * 60 * 1000,
|
range: 60 * 60 * 1000,
|
||||||
resolution: null,
|
resolution: null,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
type: PanelType.Graph,
|
type: PanelType.Graph,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -221,13 +221,12 @@ describe('Utils', () => {
|
||||||
expr: 'node_filesystem_avail_bytes',
|
expr: 'node_filesystem_avail_bytes',
|
||||||
range: 60 * 60 * 1000,
|
range: 60 * 60 * 1000,
|
||||||
resolution: null,
|
resolution: null,
|
||||||
stacked: false,
|
displayMode: GraphDisplayMode.Lines,
|
||||||
type: PanelType.Table,
|
type: PanelType.Table,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const query =
|
const query = `?g0.expr=rate(node_cpu_seconds_total%7Bmode%3D%22system%22%7D%5B1m%5D)&g0.tab=0&g0.display_mode=${GraphDisplayMode.Lines}&g0.show_exemplars=0&g0.range_input=1h&g0.end_input=2019-10-25%2023%3A37%3A00&g0.moment_input=2019-10-25%2023%3A37%3A00&g1.expr=node_filesystem_avail_bytes&g1.tab=1&g1.display_mode=${GraphDisplayMode.Lines}&g1.show_exemplars=0&g1.range_input=1h`;
|
||||||
'?g0.expr=rate(node_cpu_seconds_total%7Bmode%3D%22system%22%7D%5B1m%5D)&g0.tab=0&g0.stacked=0&g0.show_exemplars=0&g0.range_input=1h&g0.end_input=2019-10-25%2023%3A37%3A00&g0.moment_input=2019-10-25%2023%3A37%3A00&g1.expr=node_filesystem_avail_bytes&g1.tab=1&g1.stacked=0&g1.show_exemplars=0&g1.range_input=1h';
|
|
||||||
|
|
||||||
describe('decodePanelOptionsFromQueryString', () => {
|
describe('decodePanelOptionsFromQueryString', () => {
|
||||||
it('returns [] when query is empty', () => {
|
it('returns [] when query is empty', () => {
|
||||||
|
@ -246,7 +245,7 @@ describe('Utils', () => {
|
||||||
expect(parseOption('expr=foo')).toEqual({ expr: 'foo' });
|
expect(parseOption('expr=foo')).toEqual({ expr: 'foo' });
|
||||||
});
|
});
|
||||||
it('should parse stacked', () => {
|
it('should parse stacked', () => {
|
||||||
expect(parseOption('stacked=1')).toEqual({ stacked: true });
|
expect(parseOption('stacked=1')).toEqual({ displayMode: GraphDisplayMode.Stacked });
|
||||||
});
|
});
|
||||||
it('should parse end_input', () => {
|
it('should parse end_input', () => {
|
||||||
expect(parseOption('end_input=2019-10-25%2023%3A37')).toEqual({ endTime: moment.utc('2019-10-25 23:37').valueOf() });
|
expect(parseOption('end_input=2019-10-25%2023%3A37')).toEqual({ endTime: moment.utc('2019-10-25 23:37').valueOf() });
|
||||||
|
@ -294,14 +293,16 @@ describe('Utils', () => {
|
||||||
options: {
|
options: {
|
||||||
expr: 'foo',
|
expr: 'foo',
|
||||||
type: PanelType.Graph,
|
type: PanelType.Graph,
|
||||||
stacked: true,
|
displayMode: GraphDisplayMode.Stacked,
|
||||||
showExemplars: true,
|
showExemplars: true,
|
||||||
range: 0,
|
range: 0,
|
||||||
endTime: null,
|
endTime: null,
|
||||||
resolution: 1,
|
resolution: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toEqual('g0.expr=foo&g0.tab=0&g0.stacked=1&g0.show_exemplars=1&g0.range_input=0s&g0.step_input=1');
|
).toEqual(
|
||||||
|
`g0.expr=foo&g0.tab=0&g0.display_mode=${GraphDisplayMode.Stacked}&g0.show_exemplars=1&g0.range_input=0s&g0.step_input=1`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
195
web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js
vendored
Normal file
195
web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js
vendored
Normal file
|
@ -0,0 +1,195 @@
|
||||||
|
/* Flot plugin for rendering heatmap charts.
|
||||||
|
|
||||||
|
Inspired by a similar feature in VictoriaMetrics.
|
||||||
|
See https://github.com/VictoriaMetrics/VictoriaMetrics/issues/3384 for more details.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import moment from 'moment-timezone';
|
||||||
|
import {formatValue} from "../../pages/graph/GraphHelpers";
|
||||||
|
|
||||||
|
const TOOLTIP_ID = 'heatmap-tooltip';
|
||||||
|
const GRADIENT_STEPS = 16;
|
||||||
|
|
||||||
|
(function ($) {
|
||||||
|
let mouseMoveHandler = null;
|
||||||
|
|
||||||
|
function init(plot) {
|
||||||
|
plot.hooks.draw.push((plot, ctx) => {
|
||||||
|
const options = plot.getOptions();
|
||||||
|
if (!options.series.heatmap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const series = plot.getData();
|
||||||
|
const fillPalette = generateGradient("#FDF4EB", "#752E12", GRADIENT_STEPS);
|
||||||
|
const fills = countsToFills(series.flatMap(s => s.data.map(d => d[1])), fillPalette);
|
||||||
|
series.forEach((s, i) => drawHeatmap(s, plot, ctx, i, fills));
|
||||||
|
});
|
||||||
|
|
||||||
|
plot.hooks.bindEvents.push((plot, eventHolder) => {
|
||||||
|
const options = plot.getOptions();
|
||||||
|
if (!options.series.heatmap || !options.tooltip.show) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
mouseMoveHandler = (e) => {
|
||||||
|
removeTooltip();
|
||||||
|
const {left: xOffset, top: yOffset} = plot.offset();
|
||||||
|
const pos = plot.c2p({left: e.pageX - xOffset, top: e.pageY - yOffset});
|
||||||
|
const seriesIdx = Math.floor(pos.y);
|
||||||
|
const series = plot.getData();
|
||||||
|
|
||||||
|
for (let i = 0; i < series.length; i++) {
|
||||||
|
if (seriesIdx !== i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = series[i];
|
||||||
|
const label = s?.labels?.le || ""
|
||||||
|
const prevLabel = series[i - 1]?.labels?.le || ""
|
||||||
|
for (let j = 0; j < s.data.length - 1; j++) {
|
||||||
|
const [xStartVal, yStartVal] = s.data[j];
|
||||||
|
const [xEndVal] = s.data[j + 1];
|
||||||
|
const isIncluded = pos.x >= xStartVal && pos.x <= xEndVal;
|
||||||
|
if (yStartVal && isIncluded) {
|
||||||
|
showTooltip({
|
||||||
|
cssClass: options.tooltip.cssClass,
|
||||||
|
x: e.pageX,
|
||||||
|
y: e.pageY,
|
||||||
|
value: formatValue(yStartVal),
|
||||||
|
dateTime: [xStartVal, xEndVal].map(t => moment(t).format('YYYY-MM-DD HH:mm:ss Z')),
|
||||||
|
label: `${prevLabel} - ${label}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(eventHolder).on('mousemove', mouseMoveHandler);
|
||||||
|
});
|
||||||
|
|
||||||
|
plot.hooks.shutdown.push((_plot, eventHolder) => {
|
||||||
|
removeTooltip();
|
||||||
|
$(eventHolder).off("mousemove", mouseMoveHandler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip({x, y, cssClass, value, dateTime, label}) {
|
||||||
|
const tooltip = document.createElement('div');
|
||||||
|
tooltip.id = TOOLTIP_ID
|
||||||
|
tooltip.className = cssClass;
|
||||||
|
|
||||||
|
const timeHtml = `<div class="date">${dateTime.join('<br>')}</div>`
|
||||||
|
const labelHtml = `<div>Bucket: ${label || 'value'}</div>`
|
||||||
|
const valueHtml = `<div>Value: <strong>${value}</strong></div>`
|
||||||
|
tooltip.innerHTML = `<div>${timeHtml}<div>${labelHtml}${valueHtml}</div></div>`;
|
||||||
|
|
||||||
|
tooltip.style.position = 'absolute';
|
||||||
|
tooltip.style.top = y + 5 + 'px';
|
||||||
|
tooltip.style.left = x + 5 + 'px';
|
||||||
|
tooltip.style.display = 'none';
|
||||||
|
document.body.appendChild(tooltip);
|
||||||
|
|
||||||
|
const totalTipWidth = $(tooltip).outerWidth();
|
||||||
|
const totalTipHeight = $(tooltip).outerHeight();
|
||||||
|
|
||||||
|
if (x > ($(window).width() - totalTipWidth)) {
|
||||||
|
x -= totalTipWidth;
|
||||||
|
tooltip.style.left = x + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (y > ($(window).height() - totalTipHeight)) {
|
||||||
|
y -= totalTipHeight;
|
||||||
|
tooltip.style.top = y + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.style.display = 'block'; // This will trigger a re-render, allowing fadeIn to work
|
||||||
|
tooltip.style.opacity = '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTooltip() {
|
||||||
|
let tooltip = document.getElementById(TOOLTIP_ID);
|
||||||
|
if (tooltip) {
|
||||||
|
document.body.removeChild(tooltip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawHeatmap(series, plot, ctx, seriesIndex, fills) {
|
||||||
|
const {data: dataPoints} = series;
|
||||||
|
const {left: xOffset, top: yOffset} = plot.getPlotOffset();
|
||||||
|
const plotHeight = plot.height();
|
||||||
|
const xaxis = plot.getXAxes()[0];
|
||||||
|
const cellHeight = plotHeight / plot.getData().length;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.translate(xOffset, yOffset);
|
||||||
|
|
||||||
|
for (let i = 0, len = dataPoints.length - 1; i < len; i++) {
|
||||||
|
const [xStartVal, countStart] = dataPoints[i];
|
||||||
|
const [xEndVal] = dataPoints[i + 1];
|
||||||
|
|
||||||
|
const xStart = xaxis.p2c(xStartVal);
|
||||||
|
const xEnd = xaxis.p2c(xEndVal);
|
||||||
|
const cellWidth = xEnd - xStart;
|
||||||
|
const yStart = plotHeight - (seriesIndex + 1) * cellHeight;
|
||||||
|
|
||||||
|
ctx.fillStyle = fills[countStart];
|
||||||
|
ctx.fillRect(xStart + 0.5, yStart + 0.5, cellWidth - 1, cellHeight - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function countsToFills(counts, fillPalette) {
|
||||||
|
const hideThreshold = 0;
|
||||||
|
const minCount = Math.min(...counts.filter(count => count > hideThreshold));
|
||||||
|
const maxCount = Math.max(...counts);
|
||||||
|
const range = maxCount - minCount;
|
||||||
|
const paletteSize = fillPalette.length;
|
||||||
|
|
||||||
|
return counts.reduce((acc, count) => {
|
||||||
|
const index = count === 0
|
||||||
|
? -1
|
||||||
|
: Math.min(paletteSize - 1, Math.floor((paletteSize * (count - minCount)) / range));
|
||||||
|
acc[count] = fillPalette[index] || "transparent";
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateGradient(color1, color2, steps) {
|
||||||
|
function interpolateColor(startColor, endColor, step) {
|
||||||
|
let r = startColor[0] + step * (endColor[0] - startColor[0]);
|
||||||
|
let g = startColor[1] + step * (endColor[1] - startColor[1]);
|
||||||
|
let b = startColor[2] + step * (endColor[2] - startColor[2]);
|
||||||
|
|
||||||
|
return `rgb(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToRgb(hex) {
|
||||||
|
const bigint = parseInt(hex.slice(1), 16);
|
||||||
|
const r = (bigint >> 16) & 255;
|
||||||
|
const g = (bigint >> 8) & 255;
|
||||||
|
const b = bigint & 255;
|
||||||
|
|
||||||
|
return [r, g, b];
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Array(steps).fill("").map((_el, i) => {
|
||||||
|
return interpolateColor(hexToRgb(color1), hexToRgb(color2), i / (steps - 1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
jQuery.plot.plugins.push({
|
||||||
|
init,
|
||||||
|
options: {
|
||||||
|
series: {
|
||||||
|
heatmap: false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
name: 'heatmap',
|
||||||
|
version: '1.0'
|
||||||
|
});
|
||||||
|
})(jQuery);
|
Loading…
Reference in a new issue