mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -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 ReactResizeDetector from 'react-resize-detector';
|
||||
import { Legend } from './Legend';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
describe('Graph', () => {
|
||||
beforeAll(() => {
|
||||
|
@ -30,7 +31,7 @@ describe('Graph', () => {
|
|||
endTime: 1572130692,
|
||||
resolution: 28,
|
||||
},
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
data: {
|
||||
resultType: 'matrix',
|
||||
result: [
|
||||
|
@ -115,7 +116,7 @@ describe('Graph', () => {
|
|||
graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
@ -152,7 +153,7 @@ describe('Graph', () => {
|
|||
);
|
||||
});
|
||||
it('should trigger state update when stacked prop is changed', () => {
|
||||
graph.setProps({ stacked: false });
|
||||
graph.setProps({ displayMode: GraphDisplayMode.Lines });
|
||||
expect(spyState).toHaveBeenCalledWith(
|
||||
{
|
||||
chartData: {
|
||||
|
@ -177,7 +178,7 @@ describe('Graph', () => {
|
|||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572130692,
|
||||
|
@ -201,7 +202,7 @@ describe('Graph', () => {
|
|||
const graph = shallow(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
@ -221,7 +222,7 @@ describe('Graph', () => {
|
|||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
@ -240,7 +241,7 @@ describe('Graph', () => {
|
|||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
@ -261,7 +262,7 @@ describe('Graph', () => {
|
|||
const graph = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
@ -289,7 +290,7 @@ describe('Graph', () => {
|
|||
const graph: any = mount(
|
||||
<Graph
|
||||
{...({
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
queryParams: {
|
||||
startTime: 1572128592,
|
||||
endTime: 1572128598,
|
||||
|
|
|
@ -3,18 +3,20 @@ import React, { PureComponent } from 'react';
|
|||
import ReactResizeDetector from 'react-resize-detector';
|
||||
|
||||
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 { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
|
||||
import { getOptions, normalizeData, toHoverColor } from './GraphHelpers';
|
||||
import { Button } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTimes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
require('../../vendor/flot/jquery.flot');
|
||||
require('../../vendor/flot/jquery.flot.stack');
|
||||
require('../../vendor/flot/jquery.flot.time');
|
||||
require('../../vendor/flot/jquery.flot.crosshair');
|
||||
require('../../vendor/flot/jquery.flot.selection');
|
||||
require('../../vendor/flot/jquery.flot.heatmap');
|
||||
require('jquery.flot.tooltip');
|
||||
|
||||
export interface GraphProps {
|
||||
|
@ -23,7 +25,7 @@ export interface GraphProps {
|
|||
result: Array<{ metric: Metric; values?: [number, string][]; histograms?: [number, Histogram][] }>;
|
||||
};
|
||||
exemplars: ExemplarData;
|
||||
stacked: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
useLocalTime: boolean;
|
||||
showExemplars: boolean;
|
||||
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
||||
|
@ -69,11 +71,11 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
};
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps): void {
|
||||
const { data, stacked, useLocalTime, showExemplars } = this.props;
|
||||
const { data, displayMode, useLocalTime, showExemplars } = this.props;
|
||||
if (prevProps.data !== data) {
|
||||
this.selectedSeriesIndexes = [];
|
||||
this.setState({ chartData: normalizeData(this.props) }, this.plot);
|
||||
} else if (prevProps.stacked !== stacked) {
|
||||
} else if (prevProps.displayMode !== displayMode) {
|
||||
this.setState({ chartData: normalizeData(this.props) }, () => {
|
||||
if (this.selectedSeriesIndexes.length === 0) {
|
||||
this.plot();
|
||||
|
@ -143,7 +145,18 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
}
|
||||
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 => {
|
||||
|
@ -165,7 +178,10 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
const { chartData } = this.state;
|
||||
this.plot(
|
||||
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.exemplars.filter((exemplar) => {
|
||||
|
@ -190,7 +206,7 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
}
|
||||
this.rafID = requestAnimationFrame(() => {
|
||||
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,
|
||||
]);
|
||||
});
|
||||
|
@ -251,13 +267,15 @@ class Graph extends PureComponent<GraphProps, GraphState> {
|
|||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<Legend
|
||||
shouldReset={this.selectedSeriesIndexes.length === 0}
|
||||
chartData={chartData.series}
|
||||
onHover={this.handleSeriesHover}
|
||||
onLegendMouseOut={this.handleLegendMouseOut}
|
||||
onSeriesToggle={this.handleSeriesSelect}
|
||||
/>
|
||||
{this.props.displayMode !== GraphDisplayMode.Heatmap && (
|
||||
<Legend
|
||||
shouldReset={this.selectedSeriesIndexes.length === 0}
|
||||
chartData={chartData.series}
|
||||
onHover={this.handleSeriesHover}
|
||||
onLegendMouseOut={this.handleLegendMouseOut}
|
||||
onSeriesToggle={this.handleSeriesSelect}
|
||||
/>
|
||||
)}
|
||||
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
|
||||
<br style={{ clear: 'both' }} />
|
||||
</div>
|
||||
|
|
|
@ -5,13 +5,15 @@ import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'r
|
|||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons';
|
||||
import TimeInput from './TimeInput';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
const defaultGraphControlProps = {
|
||||
range: 60 * 60 * 24 * 1000,
|
||||
endTime: 1572100217898,
|
||||
useLocalTime: false,
|
||||
resolution: 10,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
isHeatmapData: false,
|
||||
showExemplars: false,
|
||||
|
||||
onChangeRange: (): void => {
|
||||
|
@ -29,6 +31,9 @@ const defaultGraphControlProps = {
|
|||
onChangeShowExemplars: (): void => {
|
||||
// Do nothing.
|
||||
},
|
||||
onChangeDisplayMode: (): void => {
|
||||
// Do nothing.
|
||||
},
|
||||
};
|
||||
|
||||
describe('GraphControls', () => {
|
||||
|
@ -163,10 +168,10 @@ describe('GraphControls', () => {
|
|||
},
|
||||
].forEach((testCase) => {
|
||||
const results: boolean[] = [];
|
||||
const onChange = (stacked: boolean): void => {
|
||||
results.push(stacked);
|
||||
const onChange = (mode: GraphDisplayMode): void => {
|
||||
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 btn = group.find(Button).filterWhere((btn) => btn.prop('title') === testCase.title);
|
||||
const onClick = btn.prop('onClick');
|
||||
|
|
|
@ -2,23 +2,24 @@ import React, { Component } from 'react';
|
|||
import { Button, ButtonGroup, Form, Input, InputGroup, InputGroupAddon } from 'reactstrap';
|
||||
|
||||
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 { formatDuration, parseDuration } from '../../utils';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
interface GraphControlsProps {
|
||||
range: number;
|
||||
endTime: number | null;
|
||||
useLocalTime: boolean;
|
||||
resolution: number | null;
|
||||
stacked: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
isHeatmapData: boolean;
|
||||
showExemplars: boolean;
|
||||
|
||||
onChangeRange: (range: number) => void;
|
||||
onChangeEndTime: (endTime: number | null) => void;
|
||||
onChangeResolution: (resolution: number | null) => void;
|
||||
onChangeStacking: (stacked: boolean) => void;
|
||||
onChangeShowExemplars: (show: boolean) => void;
|
||||
onChangeDisplayMode: (mode: GraphDisplayMode) => void;
|
||||
}
|
||||
|
||||
class GraphControls extends Component<GraphControlsProps> {
|
||||
|
@ -153,14 +154,29 @@ class GraphControls extends Component<GraphControlsProps> {
|
|||
<ButtonGroup className="stacked-input" size="sm">
|
||||
<Button
|
||||
title="Show unstacked line graph"
|
||||
onClick={() => this.props.onChangeStacking(false)}
|
||||
active={!this.props.stacked}
|
||||
onClick={() => this.props.onChangeDisplayMode(GraphDisplayMode.Lines)}
|
||||
active={this.props.displayMode === GraphDisplayMode.Lines}
|
||||
>
|
||||
<FontAwesomeIcon icon={faChartLine} fixedWidth />
|
||||
</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 />
|
||||
</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 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: {
|
||||
stack: false,
|
||||
heatmap: false,
|
||||
lines: { lineWidth: 1, steps: false, fill: true },
|
||||
shadowSize: 0,
|
||||
},
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import $ from 'jquery';
|
||||
|
||||
import { escapeHTML } from '../../utils';
|
||||
import { GraphProps, GraphData, GraphSeries, GraphExemplar } from './Graph';
|
||||
import { GraphData, GraphExemplar, GraphProps, GraphSeries } from './Graph';
|
||||
import moment from 'moment-timezone';
|
||||
import { colorPool } from './ColorPool';
|
||||
import { prepareHeatmapData } from './GraphHeatmapHelpers';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
export const formatValue = (y: number | null): string => {
|
||||
if (y === null) {
|
||||
|
@ -145,6 +147,7 @@ export const getOptions = (stacked: boolean, useLocalTime: boolean): jquery.flot
|
|||
},
|
||||
series: {
|
||||
stack: false, // Stacking is set on a per-series basis because exemplar symbols don't support it.
|
||||
heatmap: false,
|
||||
lines: {
|
||||
lineWidth: stacked ? 1 : 2,
|
||||
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
|
||||
const { startTime, endTime, resolution } = queryParams!;
|
||||
|
||||
|
@ -188,36 +191,37 @@ export const normalizeData = ({ queryParams, data, exemplars, stacked }: GraphPr
|
|||
}
|
||||
const deviation = stdDeviation(sum, values);
|
||||
|
||||
return {
|
||||
series: data.result.map(({ values, histograms, metric }, index) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data = [];
|
||||
let valuePos = 0;
|
||||
let histogramPos = 0;
|
||||
const series = data.result.map(({ values, histograms, metric }, index) => {
|
||||
// Insert nulls for all missing steps.
|
||||
const data = [];
|
||||
let valuePos = 0;
|
||||
let histogramPos = 0;
|
||||
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values && values[valuePos];
|
||||
const currentHistogram = histograms && histograms[histogramPos];
|
||||
if (currentValue && values.length > valuePos && currentValue[0] < t + resolution / 100) {
|
||||
data.push([currentValue[0] * 1000, parseValue(currentValue[1])]);
|
||||
valuePos++;
|
||||
} else if (currentHistogram && histograms.length > histogramPos && currentHistogram[0] < t + resolution / 100) {
|
||||
data.push([currentHistogram[0] * 1000, parseValue(currentHistogram[1].sum)]);
|
||||
histogramPos++;
|
||||
} else {
|
||||
data.push([t * 1000, null]);
|
||||
}
|
||||
for (let t = startTime; t <= endTime; t += resolution) {
|
||||
// Allow for floating point inaccuracy.
|
||||
const currentValue = values && values[valuePos];
|
||||
const currentHistogram = histograms && histograms[histogramPos];
|
||||
if (currentValue && values.length > valuePos && currentValue[0] < t + resolution / 100) {
|
||||
data.push([currentValue[0] * 1000, parseValue(currentValue[1])]);
|
||||
valuePos++;
|
||||
} else if (currentHistogram && histograms.length > histogramPos && currentHistogram[0] < t + resolution / 100) {
|
||||
data.push([currentHistogram[0] * 1000, parseValue(currentHistogram[1].sum)]);
|
||||
histogramPos++;
|
||||
} else {
|
||||
data.push([t * 1000, null]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colorPool[index % colorPool.length],
|
||||
stack: displayMode === GraphDisplayMode.Stacked,
|
||||
data,
|
||||
index,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
labels: metric !== null ? metric : {},
|
||||
color: colorPool[index % colorPool.length],
|
||||
stack: stacked,
|
||||
data,
|
||||
index,
|
||||
};
|
||||
}),
|
||||
return {
|
||||
series: displayMode === GraphDisplayMode.Heatmap ? prepareHeatmapData(series) : series,
|
||||
exemplars: Object.values(buckets).flatMap((bucket) => {
|
||||
if (bucket.length === 1) {
|
||||
return bucket[0];
|
||||
|
|
|
@ -3,12 +3,13 @@ import { Alert } from 'reactstrap';
|
|||
import Graph from './Graph';
|
||||
import { QueryParams, ExemplarData } from '../../types/types';
|
||||
import { isPresent } from '../../utils';
|
||||
import { GraphDisplayMode } from './Panel';
|
||||
|
||||
interface GraphTabContentProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data: any;
|
||||
exemplars: ExemplarData;
|
||||
stacked: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
useLocalTime: boolean;
|
||||
showExemplars: boolean;
|
||||
handleTimeRangeSelection: (startTime: number, endTime: number) => void;
|
||||
|
@ -19,7 +20,7 @@ interface GraphTabContentProps {
|
|||
export const GraphTabContent: FC<GraphTabContentProps> = ({
|
||||
data,
|
||||
exemplars,
|
||||
stacked,
|
||||
displayMode,
|
||||
useLocalTime,
|
||||
lastQueryParams,
|
||||
showExemplars,
|
||||
|
@ -41,7 +42,7 @@ export const GraphTabContent: FC<GraphTabContentProps> = ({
|
|||
<Graph
|
||||
data={data}
|
||||
exemplars={exemplars}
|
||||
stacked={stacked}
|
||||
displayMode={displayMode}
|
||||
useLocalTime={useLocalTime}
|
||||
showExemplars={showExemplars}
|
||||
handleTimeRangeSelection={handleTimeRangeSelection}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from 'react';
|
||||
import { mount, shallow } from 'enzyme';
|
||||
import Panel, { PanelOptions, PanelType } from './Panel';
|
||||
import Panel, { GraphDisplayMode, PanelOptions, PanelType } from './Panel';
|
||||
import GraphControls from './GraphControls';
|
||||
import { NavLink, TabPane } from 'reactstrap';
|
||||
import TimeInput from './TimeInput';
|
||||
|
@ -14,7 +14,7 @@ const defaultProps = {
|
|||
range: 10,
|
||||
endTime: 1572100217898,
|
||||
resolution: 28,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
showExemplars: true,
|
||||
},
|
||||
onOptionsChanged: (): void => {
|
||||
|
@ -84,7 +84,7 @@ describe('Panel', () => {
|
|||
range: 10,
|
||||
endTime: 1572100217898,
|
||||
resolution: 28,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
showExemplars: true,
|
||||
};
|
||||
const graphPanel = mount(<Panel {...defaultProps} options={options} />);
|
||||
|
@ -94,8 +94,8 @@ describe('Panel', () => {
|
|||
expect(controls.prop('endTime')).toEqual(options.endTime);
|
||||
expect(controls.prop('range')).toEqual(options.range);
|
||||
expect(controls.prop('resolution')).toEqual(options.resolution);
|
||||
expect(controls.prop('stacked')).toEqual(options.stacked);
|
||||
expect(graph.prop('stacked')).toEqual(options.stacked);
|
||||
expect(controls.prop('displayMode')).toEqual(options.displayMode);
|
||||
expect(graph.prop('displayMode')).toEqual(options.displayMode);
|
||||
});
|
||||
|
||||
describe('when switching between modes', () => {
|
||||
|
|
|
@ -13,6 +13,7 @@ import QueryStatsView, { QueryStats } from './QueryStatsView';
|
|||
import { QueryParams, ExemplarData } from '../../types/types';
|
||||
import { API_PATH } from '../../constants/constants';
|
||||
import { debounce } from '../../utils';
|
||||
import { isHeatmapData } from './GraphHeatmapHelpers';
|
||||
|
||||
interface PanelProps {
|
||||
options: PanelOptions;
|
||||
|
@ -39,6 +40,7 @@ interface PanelState {
|
|||
error: string | null;
|
||||
stats: QueryStats | null;
|
||||
exprInputValue: string;
|
||||
isHeatmapData: boolean;
|
||||
}
|
||||
|
||||
export interface PanelOptions {
|
||||
|
@ -47,7 +49,7 @@ export interface PanelOptions {
|
|||
range: number; // Range in milliseconds.
|
||||
endTime: number | null; // Timestamp in milliseconds.
|
||||
resolution: number | null; // Resolution in seconds.
|
||||
stacked: boolean;
|
||||
displayMode: GraphDisplayMode;
|
||||
showExemplars: boolean;
|
||||
}
|
||||
|
||||
|
@ -56,13 +58,19 @@ export enum PanelType {
|
|||
Table = 'table',
|
||||
}
|
||||
|
||||
export enum GraphDisplayMode {
|
||||
Lines = 'lines',
|
||||
Stacked = 'stacked',
|
||||
Heatmap = 'heatmap',
|
||||
}
|
||||
|
||||
export const PanelDefaultOptions: PanelOptions = {
|
||||
type: PanelType.Table,
|
||||
expr: '',
|
||||
range: 60 * 60 * 1000,
|
||||
endTime: null,
|
||||
resolution: null,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
showExemplars: false,
|
||||
};
|
||||
|
||||
|
@ -82,6 +90,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
error: null,
|
||||
stats: null,
|
||||
exprInputValue: props.options.expr,
|
||||
isHeatmapData: false,
|
||||
};
|
||||
|
||||
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({
|
||||
error: null,
|
||||
data: query.data,
|
||||
|
@ -200,6 +214,7 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
resultSeries,
|
||||
},
|
||||
loading: false,
|
||||
isHeatmapData: isHeatmap,
|
||||
});
|
||||
this.abortInFlightFetch = null;
|
||||
} catch (err: unknown) {
|
||||
|
@ -252,8 +267,8 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
this.setOptions({ type: type });
|
||||
};
|
||||
|
||||
handleChangeStacking = (stacked: boolean): void => {
|
||||
this.setOptions({ stacked: stacked });
|
||||
handleChangeDisplayMode = (mode: GraphDisplayMode): void => {
|
||||
this.setOptions({ displayMode: mode });
|
||||
};
|
||||
|
||||
handleChangeShowExemplars = (show: boolean): void => {
|
||||
|
@ -337,18 +352,19 @@ class Panel extends Component<PanelProps, PanelState> {
|
|||
endTime={options.endTime}
|
||||
useLocalTime={this.props.useLocalTime}
|
||||
resolution={options.resolution}
|
||||
stacked={options.stacked}
|
||||
displayMode={options.displayMode}
|
||||
isHeatmapData={this.state.isHeatmapData}
|
||||
showExemplars={options.showExemplars}
|
||||
onChangeRange={this.handleChangeRange}
|
||||
onChangeEndTime={this.handleChangeEndTime}
|
||||
onChangeResolution={this.handleChangeResolution}
|
||||
onChangeStacking={this.handleChangeStacking}
|
||||
onChangeDisplayMode={this.handleChangeDisplayMode}
|
||||
onChangeShowExemplars={this.handleChangeShowExemplars}
|
||||
/>
|
||||
<GraphTabContent
|
||||
data={this.state.data}
|
||||
exemplars={this.state.exemplars}
|
||||
stacked={options.stacked}
|
||||
displayMode={options.displayMode}
|
||||
useLocalTime={this.props.useLocalTime}
|
||||
showExemplars={options.showExemplars}
|
||||
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] } & {
|
||||
stack: boolean;
|
||||
heatmap: boolean;
|
||||
};
|
||||
selection: {
|
||||
mode: string;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
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';
|
||||
|
||||
export const generateID = (): string => {
|
||||
|
@ -196,8 +196,12 @@ export const parseOption = (param: string): Partial<PanelOptions> => {
|
|||
case 'tab':
|
||||
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':
|
||||
return { stacked: decodedValue === '1' };
|
||||
return { displayMode: decodedValue === '1' ? GraphDisplayMode.Stacked : GraphDisplayMode.Lines };
|
||||
|
||||
case 'show_exemplars':
|
||||
return { showExemplars: decodedValue === '1' };
|
||||
|
@ -225,12 +229,12 @@ export const formatParam =
|
|||
|
||||
export const toQueryString = ({ key, options }: PanelMeta): string => {
|
||||
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 urlParams = [
|
||||
formatWithKey('expr', expr),
|
||||
formatWithKey('tab', type === PanelType.Graph ? 0 : 1),
|
||||
formatWithKey('stacked', stacked ? 1 : 0),
|
||||
formatWithKey('display_mode', displayMode),
|
||||
formatWithKey('show_exemplars', showExemplars ? 1 : 0),
|
||||
formatWithKey('range_input', formatDuration(range)),
|
||||
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 => {
|
||||
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,
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
decodePanelOptionsFromQueryString,
|
||||
parsePrometheusFloat,
|
||||
} from '.';
|
||||
import { PanelType } from '../pages/graph/Panel';
|
||||
import { GraphDisplayMode, PanelType } from '../pages/graph/Panel';
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('escapeHTML', (): void => {
|
||||
|
@ -210,7 +210,7 @@ describe('Utils', () => {
|
|||
expr: 'rate(node_cpu_seconds_total{mode="system"}[1m])',
|
||||
range: 60 * 60 * 1000,
|
||||
resolution: null,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
type: PanelType.Graph,
|
||||
},
|
||||
},
|
||||
|
@ -221,13 +221,12 @@ describe('Utils', () => {
|
|||
expr: 'node_filesystem_avail_bytes',
|
||||
range: 60 * 60 * 1000,
|
||||
resolution: null,
|
||||
stacked: false,
|
||||
displayMode: GraphDisplayMode.Lines,
|
||||
type: PanelType.Table,
|
||||
},
|
||||
},
|
||||
];
|
||||
const query =
|
||||
'?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';
|
||||
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`;
|
||||
|
||||
describe('decodePanelOptionsFromQueryString', () => {
|
||||
it('returns [] when query is empty', () => {
|
||||
|
@ -246,7 +245,7 @@ describe('Utils', () => {
|
|||
expect(parseOption('expr=foo')).toEqual({ expr: 'foo' });
|
||||
});
|
||||
it('should parse stacked', () => {
|
||||
expect(parseOption('stacked=1')).toEqual({ stacked: true });
|
||||
expect(parseOption('stacked=1')).toEqual({ displayMode: GraphDisplayMode.Stacked });
|
||||
});
|
||||
it('should parse end_input', () => {
|
||||
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: {
|
||||
expr: 'foo',
|
||||
type: PanelType.Graph,
|
||||
stacked: true,
|
||||
displayMode: GraphDisplayMode.Stacked,
|
||||
showExemplars: true,
|
||||
range: 0,
|
||||
endTime: null,
|
||||
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