From 28470c229c18e6ef0bd0a5970db3cb28d88d8973 Mon Sep 17 00:00:00 2001 From: Boyko Date: Wed, 27 Nov 2019 17:51:40 +0200 Subject: [PATCH] React UI: Graph refactoring (#6382) * move graph related files into own folder Signed-off-by: blalov * move graph helper functions into own file Signed-off-by: blalov * fix typo in file name Signed-off-by: blalov * fix typo in file name and lint fixes Signed-off-by: blalov --- web/ui/react-app/src/Graph.tsx | 326 ------------------ web/ui/react-app/src/Panel.test.tsx | 4 +- web/ui/react-app/src/Panel.tsx | 4 +- .../react-app/src/{ => graph}/Graph.test.tsx | 222 +----------- web/ui/react-app/src/graph/Graph.tsx | 132 +++++++ .../src/{ => graph}/GraphControls.test.tsx | 2 +- .../src/{ => graph}/GraphControls.tsx | 4 +- .../react-app/src/graph/GraphHelpers.test.ts | 167 +++++++++ web/ui/react-app/src/graph/GraphHelpers.ts | 201 +++++++++++ .../src/graph/GraphTabContent.test.tsx | 45 +++ .../src/{ => graph}/GraphTabContent.tsx | 4 +- 11 files changed, 558 insertions(+), 553 deletions(-) delete mode 100644 web/ui/react-app/src/Graph.tsx rename web/ui/react-app/src/{ => graph}/Graph.test.tsx (50%) create mode 100644 web/ui/react-app/src/graph/Graph.tsx rename web/ui/react-app/src/{ => graph}/GraphControls.test.tsx (99%) rename web/ui/react-app/src/{ => graph}/GraphControls.tsx (97%) create mode 100644 web/ui/react-app/src/graph/GraphHelpers.test.ts create mode 100644 web/ui/react-app/src/graph/GraphHelpers.ts create mode 100644 web/ui/react-app/src/graph/GraphTabContent.test.tsx rename web/ui/react-app/src/{ => graph}/GraphTabContent.tsx (88%) diff --git a/web/ui/react-app/src/Graph.tsx b/web/ui/react-app/src/Graph.tsx deleted file mode 100644 index ce76cf2f42..0000000000 --- a/web/ui/react-app/src/Graph.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import $ from 'jquery'; -import React, { PureComponent } from 'react'; -import ReactResizeDetector from 'react-resize-detector'; - -import { escapeHTML } from './utils/html'; -import SeriesName from './SeriesName'; -import { Metric, QueryParams } from './types/types'; -import { isPresent } from './utils/func'; - -require('flot'); -require('flot/source/jquery.flot.crosshair'); -require('flot/source/jquery.flot.legend'); -require('flot/source/jquery.flot.time'); -require('flot/source/jquery.canvaswrapper'); -require('jquery.flot.tooltip'); - -interface GraphProps { - data: { - resultType: string; - result: Array<{ metric: Metric; values: [number, string][] }>; - }; - stacked: boolean; - queryParams: QueryParams | null; -} - -interface GraphSeries { - labels: { [key: string]: string }; - color: string; - data: (number | null)[][]; // [x,y][] - index: number; -} - -interface GraphState { - selectedSeriesIndex: number | null; - chartData: GraphSeries[]; -} - -class Graph extends PureComponent { - private chartRef = React.createRef(); - private $chart?: jquery.flot.plot; - private rafID = 0; - - state = { - selectedSeriesIndex: null, - chartData: this.getData(), - }; - - formatValue = (y: number | null): string => { - if (y === null) { - return 'null'; - } - const absY = Math.abs(y); - - if (absY >= 1e24) { - return (y / 1e24).toFixed(2) + 'Y'; - } else if (absY >= 1e21) { - return (y / 1e21).toFixed(2) + 'Z'; - } else if (absY >= 1e18) { - return (y / 1e18).toFixed(2) + 'E'; - } else if (absY >= 1e15) { - return (y / 1e15).toFixed(2) + 'P'; - } else if (absY >= 1e12) { - return (y / 1e12).toFixed(2) + 'T'; - } else if (absY >= 1e9) { - return (y / 1e9).toFixed(2) + 'G'; - } else if (absY >= 1e6) { - return (y / 1e6).toFixed(2) + 'M'; - } else if (absY >= 1e3) { - return (y / 1e3).toFixed(2) + 'k'; - } else if (absY >= 1) { - return y.toFixed(2); - } else if (absY === 0) { - return y.toFixed(2); - } else if (absY < 1e-23) { - return (y / 1e-24).toFixed(2) + 'y'; - } else if (absY < 1e-20) { - return (y / 1e-21).toFixed(2) + 'z'; - } else if (absY < 1e-17) { - return (y / 1e-18).toFixed(2) + 'a'; - } else if (absY < 1e-14) { - return (y / 1e-15).toFixed(2) + 'f'; - } else if (absY < 1e-11) { - return (y / 1e-12).toFixed(2) + 'p'; - } else if (absY < 1e-8) { - return (y / 1e-9).toFixed(2) + 'n'; - } else if (absY < 1e-5) { - return (y / 1e-6).toFixed(2) + 'µ'; - } else if (absY < 1e-2) { - return (y / 1e-3).toFixed(2) + 'm'; - } else if (absY <= 1) { - return y.toFixed(2); - } - throw Error("couldn't format a value, this is a bug"); - }; - - getOptions(): jquery.flot.plotOptions { - return { - grid: { - hoverable: true, - clickable: true, - autoHighlight: true, - mouseActiveRadius: 100, - }, - legend: { - show: false, - }, - xaxis: { - mode: 'time', - showTicks: true, - showMinorTicks: true, - timeBase: 'milliseconds', - }, - yaxis: { - tickFormatter: this.formatValue, - }, - crosshair: { - mode: 'xy', - color: '#bbb', - }, - tooltip: { - show: true, - cssClass: 'graph-tooltip', - content: (_, xval, yval, { series }): string => { - const { labels, color } = series; - return ` -
${new Date(xval).toUTCString()}
-
- - ${labels.__name__ || 'value'}: ${yval} -
-
- ${Object.keys(labels) - .map(k => - k !== '__name__' ? `
${k}: ${escapeHTML(labels[k])}
` : '' - ) - .join('')} -
- `; - }, - defaultTheme: false, - lines: true, - }, - series: { - stack: this.props.stacked, - lines: { - lineWidth: this.props.stacked ? 1 : 2, - steps: false, - fill: this.props.stacked, - }, - shadowSize: 0, - }, - }; - } - - // This was adapted from Flot's color generation code. - getColors() { - const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed']; - const colorPoolSize = colorPool.length; - let variation = 0; - return this.props.data.result.map((_, i) => { - // Each time we exhaust the colors in the pool we adjust - // a scaling factor used to produce more variations on - // those colors. The factor alternates negative/positive - // to produce lighter/darker colors. - - // Reset the variation after every few cycles, or else - // it will end up producing only white or black colors. - - if (i % colorPoolSize === 0 && i) { - if (variation >= 0) { - variation = variation < 0.5 ? -variation - 0.2 : 0; - } else { - variation = -variation; - } - } - return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation); - }); - } - - getData(): GraphSeries[] { - const colors = this.getColors(); - const { stacked, queryParams } = this.props; - const { startTime, endTime, resolution } = queryParams!; - return this.props.data.result.map(({ values, metric }, index) => { - // Insert nulls for all missing steps. - const data = []; - let pos = 0; - - for (let t = startTime; t <= endTime; t += resolution) { - // Allow for floating point inaccuracy. - const currentValue = values[pos]; - if (values.length > pos && currentValue[0] < t + resolution / 100) { - data.push([currentValue[0] * 1000, this.parseValue(currentValue[1])]); - pos++; - } else { - // TODO: Flot has problems displaying intermittent "null" values when stacked, - // resort to 0 now. In Grafana this works for some reason, figure out how they - // do it. - data.push([t * 1000, stacked ? 0 : null]); - } - } - - return { - labels: metric !== null ? metric : {}, - color: colors[index].toString(), - data, - index, - }; - }); - } - - parseValue(value: string) { - const val = parseFloat(value); - if (isNaN(val)) { - // "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They - // can't be graphed, so show them as gaps (null). - - // TODO: Flot has problems displaying intermittent "null" values when stacked, - // resort to 0 now. In Grafana this works for some reason, figure out how they - // do it. - return this.props.stacked ? 0 : null; - } - return val; - } - - componentDidUpdate(prevProps: GraphProps) { - if (prevProps.data !== this.props.data || prevProps.stacked !== this.props.stacked) { - this.setState({ selectedSeriesIndex: null, chartData: this.getData() }, this.plot); - } - } - - componentWillUnmount() { - this.destroyPlot(); - } - - plot = () => { - if (!this.chartRef.current) { - return; - } - this.destroyPlot(); - - this.$chart = $.plot($(this.chartRef.current), this.state.chartData, this.getOptions()); - }; - - destroyPlot = () => { - if (isPresent(this.$chart)) { - this.$chart.destroy(); - } - }; - - plotSetAndDraw(data: GraphSeries[] = this.state.chartData) { - if (isPresent(this.$chart)) { - this.$chart.setData(data); - this.$chart.draw(); - } - } - - handleSeriesSelect = (index: number) => () => { - const { selectedSeriesIndex, chartData } = this.state; - this.plotSetAndDraw( - selectedSeriesIndex === index ? chartData.map(this.toHoverColor(index)) : chartData.slice(index, index + 1) - ); - this.setState({ selectedSeriesIndex: selectedSeriesIndex === index ? null : index }); - }; - - handleSeriesHover = (index: number) => () => { - if (this.rafID) { - cancelAnimationFrame(this.rafID); - } - this.rafID = requestAnimationFrame(() => { - this.plotSetAndDraw(this.state.chartData.map(this.toHoverColor(index))); - }); - }; - - handleLegendMouseOut = () => { - cancelAnimationFrame(this.rafID); - this.plotSetAndDraw(); - }; - - getHoverColor = (color: string, opacity: number) => { - const { r, g, b } = $.color.parse(color); - if (!this.props.stacked) { - return `rgba(${r}, ${g}, ${b}, ${opacity})`; - } - /* - Unfortunetly flot doesn't take into consideration - the alpha value when adjusting the color on the stacked series. - TODO: find better way to set the opacity. - */ - const base = (1 - opacity) * 255; - return `rgb(${Math.round(base + opacity * r)},${Math.round(base + opacity * g)},${Math.round(base + opacity * b)})`; - }; - - toHoverColor = (index: number) => (series: GraphSeries, i: number) => ({ - ...series, - color: this.getHoverColor(series.color, i !== index ? 0.3 : 1), - }); - - render() { - const { selectedSeriesIndex, chartData } = this.state; - const canUseHover = chartData.length > 1 && selectedSeriesIndex === null; - - return ( -
- -
-
- {chartData.map(({ index, color, labels }) => ( -
1 ? this.handleSeriesSelect(index) : undefined} - onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined} - key={index} - className="legend-item" - > - - -
- ))} -
-
- ); - } -} - -export default Graph; diff --git a/web/ui/react-app/src/Panel.test.tsx b/web/ui/react-app/src/Panel.test.tsx index 8a8270ca89..e5d38841b6 100644 --- a/web/ui/react-app/src/Panel.test.tsx +++ b/web/ui/react-app/src/Panel.test.tsx @@ -2,11 +2,11 @@ import * as React from 'react'; import { mount, shallow } from 'enzyme'; import Panel, { PanelOptions, PanelType } from './Panel'; import ExpressionInput from './ExpressionInput'; -import GraphControls from './GraphControls'; +import GraphControls from './graph/GraphControls'; import { NavLink, TabPane } from 'reactstrap'; import TimeInput from './TimeInput'; import DataTable from './DataTable'; -import { GraphTabContent } from './GraphTabContent'; +import { GraphTabContent } from './graph/GraphTabContent'; describe('Panel', () => { const props = { diff --git a/web/ui/react-app/src/Panel.tsx b/web/ui/react-app/src/Panel.tsx index df04af8d4b..e8ee99508c 100644 --- a/web/ui/react-app/src/Panel.tsx +++ b/web/ui/react-app/src/Panel.tsx @@ -5,8 +5,8 @@ import { Alert, Button, Col, Nav, NavItem, NavLink, Row, TabContent, TabPane } f import moment from 'moment-timezone'; import ExpressionInput from './ExpressionInput'; -import GraphControls from './GraphControls'; -import { GraphTabContent } from './GraphTabContent'; +import GraphControls from './graph/GraphControls'; +import { GraphTabContent } from './graph/GraphTabContent'; import DataTable from './DataTable'; import TimeInput from './TimeInput'; import QueryStatsView, { QueryStats } from './QueryStatsView'; diff --git a/web/ui/react-app/src/Graph.test.tsx b/web/ui/react-app/src/graph/Graph.test.tsx similarity index 50% rename from web/ui/react-app/src/Graph.test.tsx rename to web/ui/react-app/src/graph/Graph.test.tsx index 8f8166c832..daf985b7d6 100644 --- a/web/ui/react-app/src/Graph.test.tsx +++ b/web/ui/react-app/src/graph/Graph.test.tsx @@ -68,94 +68,10 @@ describe('Graph', () => { }); }); }); - describe('formatValue', () => { - it('formats tick values correctly', () => { - const graph = new Graph({ data: { result: [] }, queryParams: {} } as any); - [ - { input: null, output: 'null' }, - { input: 0, output: '0.00' }, - { input: 2e24, output: '2.00Y' }, - { input: 2e23, output: '200.00Z' }, - { input: 2e22, output: '20.00Z' }, - { input: 2e21, output: '2.00Z' }, - { input: 2e19, output: '20.00E' }, - { input: 2e18, output: '2.00E' }, - { input: 2e17, output: '200.00P' }, - { input: 2e16, output: '20.00P' }, - { input: 2e15, output: '2.00P' }, - { input: 1e15, output: '1.00P' }, - { input: 2e14, output: '200.00T' }, - { input: 2e13, output: '20.00T' }, - { input: 2e12, output: '2.00T' }, - { input: 2e11, output: '200.00G' }, - { input: 2e10, output: '20.00G' }, - { input: 2e9, output: '2.00G' }, - { input: 2e8, output: '200.00M' }, - { input: 2e7, output: '20.00M' }, - { input: 2e6, output: '2.00M' }, - { input: 2e5, output: '200.00k' }, - { input: 2e4, output: '20.00k' }, - { input: 2e3, output: '2.00k' }, - { input: 2e2, output: '200.00' }, - { input: 2e1, output: '20.00' }, - { input: 2, output: '2.00' }, - { input: 2e-1, output: '0.20' }, - { input: 2e-2, output: '0.02' }, - { input: 2e-3, output: '2.00m' }, - { input: 2e-4, output: '0.20m' }, - { input: 2e-5, output: '0.02m' }, - { input: 2e-6, output: '2.00µ' }, - { input: 2e-7, output: '0.20µ' }, - { input: 2e-8, output: '0.02µ' }, - { input: 2e-9, output: '2.00n' }, - { input: 2e-10, output: '0.20n' }, - { input: 2e-11, output: '0.02n' }, - { input: 2e-12, output: '2.00p' }, - { input: 2e-13, output: '0.20p' }, - { input: 2e-14, output: '0.02p' }, - { input: 2e-15, output: '2.00f' }, - { input: 2e-16, output: '0.20f' }, - { input: 2e-17, output: '0.02f' }, - { input: 2e-18, output: '2.00a' }, - { input: 2e-19, output: '0.20a' }, - { input: 2e-20, output: '0.02a' }, - { input: 1e-21, output: '1.00z' }, - { input: 2e-21, output: '2.00z' }, - { input: 2e-22, output: '0.20z' }, - { input: 2e-23, output: '0.02z' }, - { input: 2e-24, output: '2.00y' }, - { input: 2e-25, output: '0.20y' }, - { input: 2e-26, output: '0.02y' }, - ].map(t => { - expect(graph.formatValue(t.input)).toBe(t.output); - }); - }); - it('should throw error if no match', () => { - const graph = new Graph({ data: { result: [] }, queryParams: {} } as any); - try { - graph.formatValue(undefined as any); - } catch (error) { - expect(error.message).toEqual("couldn't format a value, this is a bug"); - } - }); - }); - describe('getColors', () => { - it('should generate proper colors', () => { - const graph = new Graph({ data: { result: [{}, {}, {}, {}, {}, {}, {}, {}, {}] }, queryParams: {} } as any); - expect( - graph - .getColors() - .map(c => c.toString()) - .join(',') - ).toEqual( - 'rgb(237,194,64),rgb(175,216,248),rgb(203,75,75),rgb(77,167,77),rgb(148,64,237),rgb(189,155,51),rgb(140,172,198),rgb(162,60,60),rgb(61,133,61)' - ); - }); - }); describe('on component update', () => { let graph: any; beforeEach(() => { - jest.spyOn($, 'plot').mockImplementation(() => {}); + jest.spyOn($, 'plot').mockImplementation(() => ({} as any)); graph = mount( { expect(spyPlotDestroy).toHaveBeenCalledTimes(1); }); }); - describe('parseValue', () => { - it('should parse number properly', () => { - expect(new Graph({ stacked: true, data: { result: [] }, queryParams: {} } as any).parseValue('12.3e')).toEqual(12.3); - }); - it('should return 0 if value is NaN and stacked prop is true', () => { - expect(new Graph({ stacked: true, data: { result: [] }, queryParams: {} } as any).parseValue('asd')).toEqual(0); - }); - it('should return null if value is NaN and stacked prop is false', () => { - expect(new Graph({ stacked: false, data: { result: [] }, queryParams: {} } as any).parseValue('asd')).toBeNull(); - }); - }); describe('plot', () => { let spyFlot: any; beforeEach(() => { - spyFlot = jest.spyOn($, 'plot').mockImplementation(() => {}); + spyFlot = jest.spyOn($, 'plot').mockImplementation(() => ({} as any)); }); afterAll(() => { jest.restoreAllMocks(); }); - it('should not call jquery.plot if charRef not exist', () => { + it('should not call jquery.plot if chartRef not exist', () => { const graph = shallow( { (graph.instance() as any).plot(); expect(spyFlot).not.toBeCalled(); }); - it('should call jquery.plot if charRef exist', () => { + it('should call jquery.plot if chartRef exist', () => { const graph = mount( { expect(spySetData).toHaveBeenCalledWith(graph.state().chartData); }); }); - describe('Plot options', () => { - it('should configer options properly if stacked prop is true', () => { - const wrapper = shallow( - - ); - expect((wrapper.instance() as any).getOptions()).toMatchObject({ - series: { - stack: true, - lines: { lineWidth: 1, steps: false, fill: true }, - shadowSize: 0, - }, - }); - }); - it('should configer options properly if stacked prop is false', () => { - const wrapper = shallow( - - ); - expect((wrapper.instance() as any).getOptions()).toMatchObject({ - series: { - stack: false, - lines: { lineWidth: 2, steps: false, fill: false }, - shadowSize: 0, - }, - }); - }); - it('should return proper tooltip html from options', () => { - const wrapper = shallow( - - ); - expect( - (wrapper.instance() as any) - .getOptions() - .tooltip.content('', 1572128592, 1572128592, { series: { labels: { foo: 1, bar: 2 }, color: '' } }) - ).toEqual(` -
Mon, 19 Jan 1970 04:42:08 GMT
-
- - value: 1572128592 -
-
-
foo: 1
bar: 2
-
- `); - }); - it('should render Plot with proper options', () => { - const wrapper = mount( - - ); - expect((wrapper.instance() as any).getOptions()).toEqual({ - grid: { - hoverable: true, - clickable: true, - autoHighlight: true, - mouseActiveRadius: 100, - }, - legend: { show: false }, - xaxis: { - mode: 'time', - showTicks: true, - showMinorTicks: true, - timeBase: 'milliseconds', - }, - yaxis: { tickFormatter: expect.anything() }, - crosshair: { mode: 'xy', color: '#bbb' }, - tooltip: { - show: true, - cssClass: 'graph-tooltip', - content: expect.anything(), - defaultTheme: false, - lines: true, - }, - series: { - stack: true, - lines: { lineWidth: 1, steps: false, fill: true }, - shadowSize: 0, - }, - }); - }); - }); }); diff --git a/web/ui/react-app/src/graph/Graph.tsx b/web/ui/react-app/src/graph/Graph.tsx new file mode 100644 index 0000000000..1ba196e7c2 --- /dev/null +++ b/web/ui/react-app/src/graph/Graph.tsx @@ -0,0 +1,132 @@ +import $ from 'jquery'; +import React, { PureComponent } from 'react'; +import ReactResizeDetector from 'react-resize-detector'; + +import SeriesName from '../SeriesName'; +import { Metric, QueryParams } from '../types/types'; +import { isPresent } from '../utils/func'; +import { normalizeData, getOptions, toHoverColor } from './GraphHelpers'; + +require('flot'); +require('flot/source/jquery.flot.crosshair'); +require('flot/source/jquery.flot.legend'); +require('flot/source/jquery.flot.time'); +require('flot/source/jquery.canvaswrapper'); +require('jquery.flot.tooltip'); + +export interface GraphProps { + data: { + resultType: string; + result: Array<{ metric: Metric; values: [number, string][] }>; + }; + stacked: boolean; + queryParams: QueryParams | null; +} + +export interface GraphSeries { + labels: { [key: string]: string }; + color: string; + data: (number | null)[][]; // [x,y][] + index: number; +} + +interface GraphState { + selectedSeriesIndex: number | null; + chartData: GraphSeries[]; +} + +class Graph extends PureComponent { + private chartRef = React.createRef(); + private $chart?: jquery.flot.plot; + private rafID = 0; + + state = { + selectedSeriesIndex: null, + chartData: normalizeData(this.props), + }; + + componentDidUpdate(prevProps: GraphProps) { + const { data, stacked } = this.props; + if (prevProps.data !== data || prevProps.stacked !== stacked) { + this.setState({ selectedSeriesIndex: null, chartData: normalizeData(this.props) }, this.plot); + } + } + + componentWillUnmount() { + this.destroyPlot(); + } + + plot = () => { + if (!this.chartRef.current) { + return; + } + this.destroyPlot(); + + this.$chart = $.plot($(this.chartRef.current), this.state.chartData, getOptions(this.props.stacked)); + }; + + destroyPlot = () => { + if (isPresent(this.$chart)) { + this.$chart.destroy(); + } + }; + + plotSetAndDraw(data: GraphSeries[] = this.state.chartData) { + if (isPresent(this.$chart)) { + this.$chart.setData(data); + this.$chart.draw(); + } + } + + handleSeriesSelect = (index: number) => () => { + const { selectedSeriesIndex, chartData } = this.state; + this.plotSetAndDraw( + selectedSeriesIndex === index + ? chartData.map(toHoverColor(index, this.props.stacked)) + : chartData.slice(index, index + 1) + ); + this.setState({ selectedSeriesIndex: selectedSeriesIndex === index ? null : index }); + }; + + handleSeriesHover = (index: number) => () => { + if (this.rafID) { + cancelAnimationFrame(this.rafID); + } + this.rafID = requestAnimationFrame(() => { + this.plotSetAndDraw(this.state.chartData.map(toHoverColor(index, this.props.stacked))); + }); + }; + + handleLegendMouseOut = () => { + cancelAnimationFrame(this.rafID); + this.plotSetAndDraw(); + }; + + render() { + const { selectedSeriesIndex, chartData } = this.state; + const canUseHover = chartData.length > 1 && selectedSeriesIndex === null; + + return ( +
+ +
+
+ {chartData.map(({ index, color, labels }) => ( +
1 ? this.handleSeriesSelect(index) : undefined} + onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined} + key={index} + className="legend-item" + > + + +
+ ))} +
+
+ ); + } +} + +export default Graph; diff --git a/web/ui/react-app/src/GraphControls.test.tsx b/web/ui/react-app/src/graph/GraphControls.test.tsx similarity index 99% rename from web/ui/react-app/src/GraphControls.test.tsx rename to web/ui/react-app/src/graph/GraphControls.test.tsx index ad0a66bafc..46059cf845 100755 --- a/web/ui/react-app/src/GraphControls.test.tsx +++ b/web/ui/react-app/src/graph/GraphControls.test.tsx @@ -4,7 +4,7 @@ import GraphControls from './GraphControls'; import { Button, ButtonGroup, Form, InputGroup, InputGroupAddon, Input } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { faPlus, faMinus, faChartArea, faChartLine } from '@fortawesome/free-solid-svg-icons'; -import TimeInput from './TimeInput'; +import TimeInput from '../TimeInput'; const defaultGraphControlProps = { range: 60 * 60 * 24, diff --git a/web/ui/react-app/src/GraphControls.tsx b/web/ui/react-app/src/graph/GraphControls.tsx similarity index 97% rename from web/ui/react-app/src/GraphControls.tsx rename to web/ui/react-app/src/graph/GraphControls.tsx index 3f494ae4fc..69803a145a 100644 --- a/web/ui/react-app/src/GraphControls.tsx +++ b/web/ui/react-app/src/graph/GraphControls.tsx @@ -4,8 +4,8 @@ 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 { parseRange, formatRange } from './utils/timeFormat'; +import TimeInput from '../TimeInput'; +import { parseRange, formatRange } from '../utils/timeFormat'; interface GraphControlsProps { range: number; diff --git a/web/ui/react-app/src/graph/GraphHelpers.test.ts b/web/ui/react-app/src/graph/GraphHelpers.test.ts new file mode 100644 index 0000000000..c982fe9226 --- /dev/null +++ b/web/ui/react-app/src/graph/GraphHelpers.test.ts @@ -0,0 +1,167 @@ +import { formatValue, getColors, parseValue, getOptions } from './GraphHelpers'; +require('flot'); // need for $.colors + +describe('GraphHelpers', () => { + describe('formatValue', () => { + it('formats tick values correctly', () => { + [ + { input: null, output: 'null' }, + { input: 0, output: '0.00' }, + { input: 2e24, output: '2.00Y' }, + { input: 2e23, output: '200.00Z' }, + { input: 2e22, output: '20.00Z' }, + { input: 2e21, output: '2.00Z' }, + { input: 2e19, output: '20.00E' }, + { input: 2e18, output: '2.00E' }, + { input: 2e17, output: '200.00P' }, + { input: 2e16, output: '20.00P' }, + { input: 2e15, output: '2.00P' }, + { input: 1e15, output: '1.00P' }, + { input: 2e14, output: '200.00T' }, + { input: 2e13, output: '20.00T' }, + { input: 2e12, output: '2.00T' }, + { input: 2e11, output: '200.00G' }, + { input: 2e10, output: '20.00G' }, + { input: 2e9, output: '2.00G' }, + { input: 2e8, output: '200.00M' }, + { input: 2e7, output: '20.00M' }, + { input: 2e6, output: '2.00M' }, + { input: 2e5, output: '200.00k' }, + { input: 2e4, output: '20.00k' }, + { input: 2e3, output: '2.00k' }, + { input: 2e2, output: '200.00' }, + { input: 2e1, output: '20.00' }, + { input: 2, output: '2.00' }, + { input: 2e-1, output: '0.20' }, + { input: 2e-2, output: '0.02' }, + { input: 2e-3, output: '2.00m' }, + { input: 2e-4, output: '0.20m' }, + { input: 2e-5, output: '0.02m' }, + { input: 2e-6, output: '2.00µ' }, + { input: 2e-7, output: '0.20µ' }, + { input: 2e-8, output: '0.02µ' }, + { input: 2e-9, output: '2.00n' }, + { input: 2e-10, output: '0.20n' }, + { input: 2e-11, output: '0.02n' }, + { input: 2e-12, output: '2.00p' }, + { input: 2e-13, output: '0.20p' }, + { input: 2e-14, output: '0.02p' }, + { input: 2e-15, output: '2.00f' }, + { input: 2e-16, output: '0.20f' }, + { input: 2e-17, output: '0.02f' }, + { input: 2e-18, output: '2.00a' }, + { input: 2e-19, output: '0.20a' }, + { input: 2e-20, output: '0.02a' }, + { input: 1e-21, output: '1.00z' }, + { input: 2e-21, output: '2.00z' }, + { input: 2e-22, output: '0.20z' }, + { input: 2e-23, output: '0.02z' }, + { input: 2e-24, output: '2.00y' }, + { input: 2e-25, output: '0.20y' }, + { input: 2e-26, output: '0.02y' }, + ].map(t => { + expect(formatValue(t.input)).toBe(t.output); + }); + }); + it('should throw error if no match', () => { + try { + formatValue(undefined as any); + } catch (error) { + expect(error.message).toEqual("couldn't format a value, this is a bug"); + } + }); + }); + describe('getColors', () => { + it('should generate proper colors', () => { + const data: any = { + resultType: 'matrix', + result: [{}, {}, {}, {}, {}, {}, {}], + }; + expect( + getColors(data) + .map(c => c.toString()) + .join(',') + ).toEqual( + 'rgb(237,194,64),rgb(175,216,248),rgb(203,75,75),rgb(77,167,77),rgb(148,64,237),rgb(189,155,51),rgb(140,172,198)' + ); + }); + }); + describe('parseValue', () => { + it('should parse number properly', () => { + expect(parseValue('12.3e', true)).toEqual(12.3); + }); + it('should return 0 if value is NaN and stacked prop is true', () => { + expect(parseValue('asd', true)).toEqual(0); + }); + it('should return null if value is NaN and stacked prop is false', () => { + expect(parseValue('asd', false)).toBeNull(); + }); + }); + describe('Plot options', () => { + it('should configer options properly if stacked prop is true', () => { + expect(getOptions(true)).toMatchObject({ + series: { + stack: true, + lines: { lineWidth: 1, steps: false, fill: true }, + shadowSize: 0, + }, + }); + }); + it('should configer options properly if stacked prop is false', () => { + expect(getOptions(false)).toMatchObject({ + series: { + stack: false, + lines: { lineWidth: 2, steps: false, fill: false }, + shadowSize: 0, + }, + }); + }); + it('should return proper tooltip html from options', () => { + expect( + getOptions(true).tooltip.content('', 1572128592, 1572128592, { + series: { labels: { foo: '1', bar: '2' }, color: '' }, + } as any) + ).toEqual(` +
Mon, 19 Jan 1970 04:42:08 GMT
+
+ + value: 1572128592 +
+
+
foo: 1
bar: 2
+
+ `); + }); + it('should render Plot with proper options', () => { + expect(getOptions(true)).toEqual({ + grid: { + hoverable: true, + clickable: true, + autoHighlight: true, + mouseActiveRadius: 100, + }, + legend: { show: false }, + xaxis: { + mode: 'time', + showTicks: true, + showMinorTicks: true, + timeBase: 'milliseconds', + }, + yaxis: { tickFormatter: expect.anything() }, + crosshair: { mode: 'xy', color: '#bbb' }, + tooltip: { + show: true, + cssClass: 'graph-tooltip', + content: expect.anything(), + defaultTheme: false, + lines: true, + }, + series: { + stack: true, + lines: { lineWidth: 1, steps: false, fill: true }, + shadowSize: 0, + }, + }); + }); + }); +}); diff --git a/web/ui/react-app/src/graph/GraphHelpers.ts b/web/ui/react-app/src/graph/GraphHelpers.ts new file mode 100644 index 0000000000..2acb0ea365 --- /dev/null +++ b/web/ui/react-app/src/graph/GraphHelpers.ts @@ -0,0 +1,201 @@ +import $ from 'jquery'; + +import { escapeHTML } from '../utils/html'; +import { Metric } from '../types/types'; +import { GraphProps, GraphSeries } from './Graph'; + +export const formatValue = (y: number | null): string => { + if (y === null) { + return 'null'; + } + const absY = Math.abs(y); + + if (absY >= 1e24) { + return (y / 1e24).toFixed(2) + 'Y'; + } else if (absY >= 1e21) { + return (y / 1e21).toFixed(2) + 'Z'; + } else if (absY >= 1e18) { + return (y / 1e18).toFixed(2) + 'E'; + } else if (absY >= 1e15) { + return (y / 1e15).toFixed(2) + 'P'; + } else if (absY >= 1e12) { + return (y / 1e12).toFixed(2) + 'T'; + } else if (absY >= 1e9) { + return (y / 1e9).toFixed(2) + 'G'; + } else if (absY >= 1e6) { + return (y / 1e6).toFixed(2) + 'M'; + } else if (absY >= 1e3) { + return (y / 1e3).toFixed(2) + 'k'; + } else if (absY >= 1) { + return y.toFixed(2); + } else if (absY === 0) { + return y.toFixed(2); + } else if (absY < 1e-23) { + return (y / 1e-24).toFixed(2) + 'y'; + } else if (absY < 1e-20) { + return (y / 1e-21).toFixed(2) + 'z'; + } else if (absY < 1e-17) { + return (y / 1e-18).toFixed(2) + 'a'; + } else if (absY < 1e-14) { + return (y / 1e-15).toFixed(2) + 'f'; + } else if (absY < 1e-11) { + return (y / 1e-12).toFixed(2) + 'p'; + } else if (absY < 1e-8) { + return (y / 1e-9).toFixed(2) + 'n'; + } else if (absY < 1e-5) { + return (y / 1e-6).toFixed(2) + 'µ'; + } else if (absY < 1e-2) { + return (y / 1e-3).toFixed(2) + 'm'; + } else if (absY <= 1) { + return y.toFixed(2); + } + throw Error("couldn't format a value, this is a bug"); +}; + +export const getHoverColor = (color: string, opacity: number, stacked: boolean) => { + const { r, g, b } = $.color.parse(color); + if (!stacked) { + return `rgba(${r}, ${g}, ${b}, ${opacity})`; + } + /* + Unfortunetly flot doesn't take into consideration + the alpha value when adjusting the color on the stacked series. + TODO: find better way to set the opacity. + */ + const base = (1 - opacity) * 255; + return `rgb(${Math.round(base + opacity * r)},${Math.round(base + opacity * g)},${Math.round(base + opacity * b)})`; +}; + +export const toHoverColor = (index: number, stacked: boolean) => (series: GraphSeries, i: number) => ({ + ...series, + color: getHoverColor(series.color, i !== index ? 0.3 : 1, stacked), +}); + +export const getOptions = (stacked: boolean): jquery.flot.plotOptions => { + return { + grid: { + hoverable: true, + clickable: true, + autoHighlight: true, + mouseActiveRadius: 100, + }, + legend: { + show: false, + }, + xaxis: { + mode: 'time', + showTicks: true, + showMinorTicks: true, + timeBase: 'milliseconds', + }, + yaxis: { + tickFormatter: formatValue, + }, + crosshair: { + mode: 'xy', + color: '#bbb', + }, + tooltip: { + show: true, + cssClass: 'graph-tooltip', + content: (_, xval, yval, { series }): string => { + const { labels, color } = series; + return ` +
${new Date(xval).toUTCString()}
+
+ + ${labels.__name__ || 'value'}: ${yval} +
+
+ ${Object.keys(labels) + .map(k => + k !== '__name__' ? `
${k}: ${escapeHTML(labels[k])}
` : '' + ) + .join('')} +
+ `; + }, + defaultTheme: false, + lines: true, + }, + series: { + stack: stacked, + lines: { + lineWidth: stacked ? 1 : 2, + steps: false, + fill: stacked, + }, + shadowSize: 0, + }, + }; +}; + +// This was adapted from Flot's color generation code. +export const getColors = (data: { resultType: string; result: Array<{ metric: Metric; values: [number, string][] }> }) => { + const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed']; + const colorPoolSize = colorPool.length; + let variation = 0; + return data.result.map((_, i) => { + // Each time we exhaust the colors in the pool we adjust + // a scaling factor used to produce more variations on + // those colors. The factor alternates negative/positive + // to produce lighter/darker colors. + + // Reset the variation after every few cycles, or else + // it will end up producing only white or black colors. + + if (i % colorPoolSize === 0 && i) { + if (variation >= 0) { + variation = variation < 0.5 ? -variation - 0.2 : 0; + } else { + variation = -variation; + } + } + return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation); + }); +}; + +export const normalizeData = ({ stacked, queryParams, data }: GraphProps): GraphSeries[] => { + const colors = getColors(data); + const { startTime, endTime, resolution } = queryParams!; + return data.result.map(({ values, metric }, index) => { + // Insert nulls for all missing steps. + const data = []; + let pos = 0; + + for (let t = startTime; t <= endTime; t += resolution) { + // Allow for floating point inaccuracy. + const currentValue = values[pos]; + if (values.length > pos && currentValue[0] < t + resolution / 100) { + data.push([currentValue[0] * 1000, parseValue(currentValue[1], stacked)]); + pos++; + } else { + // TODO: Flot has problems displaying intermittent "null" values when stacked, + // resort to 0 now. In Grafana this works for some reason, figure out how they + // do it. + data.push([t * 1000, stacked ? 0 : null]); + } + } + + return { + labels: metric !== null ? metric : {}, + color: colors[index].toString(), + data, + index, + }; + }); +}; + +export const parseValue = (value: string, stacked: boolean) => { + const val = parseFloat(value); + if (isNaN(val)) { + // "+Inf", "-Inf", "+Inf" will be parsed into NaN by parseFloat(). They + // can't be graphed, so show them as gaps (null). + + // TODO: Flot has problems displaying intermittent "null" values when stacked, + // resort to 0 now. In Grafana this works for some reason, figure out how they + // do it. + return stacked ? 0 : null; + } + return val; +}; diff --git a/web/ui/react-app/src/graph/GraphTabContent.test.tsx b/web/ui/react-app/src/graph/GraphTabContent.test.tsx new file mode 100644 index 0000000000..591ab6eafa --- /dev/null +++ b/web/ui/react-app/src/graph/GraphTabContent.test.tsx @@ -0,0 +1,45 @@ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { Alert } from 'reactstrap'; +import { GraphTabContent } from './GraphTabContent'; + +describe('GraphTabContent', () => { + it('renders an alert if data result type is different than "matrix"', () => { + const props: any = { + data: { resultType: 'invalid', result: [{}] }, + stacked: false, + queryParams: { + startTime: 1572100210000, + endTime: 1572100217898, + resolution: 10, + }, + color: 'danger', + children: `Query result is of wrong type '`, + }; + const graph = shallow(); + const alert = graph.find(Alert); + expect(alert.prop('color')).toEqual(props.color); + expect(alert.childAt(0).text()).toEqual(props.children); + }); + + it('renders an alert if data result empty', () => { + const props: any = { + data: { + resultType: 'matrix', + result: [], + }, + color: 'secondary', + children: 'Empty query result', + stacked: false, + queryParams: { + startTime: 1572100210000, + endTime: 1572100217898, + resolution: 10, + }, + }; + const graph = shallow(); + const alert = graph.find(Alert); + expect(alert.prop('color')).toEqual(props.color); + expect(alert.childAt(0).text()).toEqual(props.children); + }); +}); diff --git a/web/ui/react-app/src/GraphTabContent.tsx b/web/ui/react-app/src/graph/GraphTabContent.tsx similarity index 88% rename from web/ui/react-app/src/GraphTabContent.tsx rename to web/ui/react-app/src/graph/GraphTabContent.tsx index b8f6d1fb72..9284f99066 100644 --- a/web/ui/react-app/src/GraphTabContent.tsx +++ b/web/ui/react-app/src/graph/GraphTabContent.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { Alert } from 'reactstrap'; import Graph from './Graph'; -import { QueryParams } from './types/types'; -import { isPresent } from './utils/func'; +import { QueryParams } from '../types/types'; +import { isPresent } from '../utils/func'; export const GraphTabContent = ({ data,