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:
Yury Molodov 2023-11-24 22:44:48 +01:00 committed by GitHub
parent eda73dd3e5
commit 2e205ee95c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 413 additions and 92 deletions

View file

@ -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,

View file

@ -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>

View file

@ -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');

View file

@ -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">

View 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);
}
}

View file

@ -212,6 +212,7 @@ describe('GraphHelpers', () => {
},
series: {
stack: false,
heatmap: false,
lines: { lineWidth: 1, steps: false, fill: true },
shadowSize: 0,
},

View file

@ -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];

View file

@ -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}

View file

@ -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', () => {

View file

@ -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}

View file

@ -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;

View file

@ -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,

View file

@ -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`
);
});
});

View 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);