diff --git a/web/ui/react-app/src/pages/graph/Graph.test.tsx b/web/ui/react-app/src/pages/graph/Graph.test.tsx index 7b226792ce..01625f7c2f 100644 --- a/web/ui/react-app/src/pages/graph/Graph.test.tsx +++ b/web/ui/react-app/src/pages/graph/Graph.test.tsx @@ -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( { ); }); 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( { const graph = shallow( { const graph = mount( { const graph = mount( { const graph = mount( { const graph: any = mount( ; }; exemplars: ExemplarData; - stacked: boolean; + displayMode: GraphDisplayMode; useLocalTime: boolean; showExemplars: boolean; handleTimeRangeSelection: (startTime: number, endTime: number) => void; @@ -69,11 +71,11 @@ class Graph extends PureComponent { }; 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 { } 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 { 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 { } 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 { ) : null} - + {this.props.displayMode !== GraphDisplayMode.Heatmap && ( + + )} {/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
diff --git a/web/ui/react-app/src/pages/graph/GraphControls.test.tsx b/web/ui/react-app/src/pages/graph/GraphControls.test.tsx index 3d1961714d..b5d843ebea 100755 --- a/web/ui/react-app/src/pages/graph/GraphControls.test.tsx +++ b/web/ui/react-app/src/pages/graph/GraphControls.test.tsx @@ -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(); + const controls = shallow(); const group = controls.find(ButtonGroup); const btn = group.find(Button).filterWhere((btn) => btn.prop('title') === testCase.title); const onClick = btn.prop('onClick'); diff --git a/web/ui/react-app/src/pages/graph/GraphControls.tsx b/web/ui/react-app/src/pages/graph/GraphControls.tsx index 969400bfdd..f71b46af5e 100644 --- a/web/ui/react-app/src/pages/graph/GraphControls.tsx +++ b/web/ui/react-app/src/pages/graph/GraphControls.tsx @@ -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 { @@ -153,14 +154,29 @@ class GraphControls extends Component { - + {/* 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 && ( + + )} diff --git a/web/ui/react-app/src/pages/graph/GraphHeatmapHelpers.ts b/web/ui/react-app/src/pages/graph/GraphHeatmapHelpers.ts new file mode 100644 index 0000000000..d564959cb8 --- /dev/null +++ b/web/ui/react-app/src/pages/graph/GraphHeatmapHelpers.ts @@ -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); + } +} diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts index 5fb0675a40..206cc59a13 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.test.ts @@ -212,6 +212,7 @@ describe('GraphHelpers', () => { }, series: { stack: false, + heatmap: false, lines: { lineWidth: 1, steps: false, fill: true }, shadowSize: 0, }, diff --git a/web/ui/react-app/src/pages/graph/GraphHelpers.ts b/web/ui/react-app/src/pages/graph/GraphHelpers.ts index f77383f568..21bb768f52 100644 --- a/web/ui/react-app/src/pages/graph/GraphHelpers.ts +++ b/web/ui/react-app/src/pages/graph/GraphHelpers.ts @@ -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]; diff --git a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx index e31c7a280f..a5e7529945 100644 --- a/web/ui/react-app/src/pages/graph/GraphTabContent.tsx +++ b/web/ui/react-app/src/pages/graph/GraphTabContent.tsx @@ -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 = ({ data, exemplars, - stacked, + displayMode, useLocalTime, lastQueryParams, showExemplars, @@ -41,7 +42,7 @@ export const GraphTabContent: FC = ({ { @@ -84,7 +84,7 @@ describe('Panel', () => { range: 10, endTime: 1572100217898, resolution: 28, - stacked: false, + displayMode: GraphDisplayMode.Lines, showExemplars: true, }; const graphPanel = mount(); @@ -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', () => { diff --git a/web/ui/react-app/src/pages/graph/Panel.tsx b/web/ui/react-app/src/pages/graph/Panel.tsx index 244a7606e7..501ab67c94 100644 --- a/web/ui/react-app/src/pages/graph/Panel.tsx +++ b/web/ui/react-app/src/pages/graph/Panel.tsx @@ -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 { 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 { } } + 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 { resultSeries, }, loading: false, + isHeatmapData: isHeatmap, }); this.abortInFlightFetch = null; } catch (err: unknown) { @@ -252,8 +267,8 @@ class Panel extends Component { 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 { 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} /> { @@ -196,8 +196,12 @@ export const parseOption = (param: string): Partial => { 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, diff --git a/web/ui/react-app/src/utils/utils.test.ts b/web/ui/react-app/src/utils/utils.test.ts index 4db3e28a64..4b14465816 100644 --- a/web/ui/react-app/src/utils/utils.test.ts +++ b/web/ui/react-app/src/utils/utils.test.ts @@ -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` + ); }); }); diff --git a/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js b/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js new file mode 100644 index 0000000000..29d5c81ef7 --- /dev/null +++ b/web/ui/react-app/src/vendor/flot/jquery.flot.heatmap.js @@ -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 = `
${dateTime.join('
')}
` + const labelHtml = `
Bucket: ${label || 'value'}
` + const valueHtml = `
Value: ${value}
` + tooltip.innerHTML = `
${timeHtml}
${labelHtml}${valueHtml}
`; + + 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);