diff --git a/web/ui/react-app/src/App.css b/web/ui/react-app/src/App.css index d8c7f1b9cf..e9bf97adb7 100644 --- a/web/ui/react-app/src/App.css +++ b/web/ui/react-app/src/App.css @@ -135,16 +135,31 @@ div.time-input { } .graph-legend { - margin: 15px 0 15px 25px; - font-size: 0.8em; + margin: 15px 0 15px 55px; + font-size: 0.75em; + padding: 10px 5px; + display: inline-block; + cursor: pointer; } -.graph-legend .legend-swatch { - padding: 5px; - height: 5px; +.legend-item { + display: flex; + align-items: center; + padding: 0 5px; + border-radius: 3px; +} + +.legend-swatch { + width: 7px; + height: 7px; outline-offset: 1px; outline: 1.5px solid #ccc; margin: 2px 8px 2px 0; + display: inline-block; +} + +.legend-item:hover { + background: rgba(0, 0, 0, 0.18); } .legend-metric-name { @@ -191,7 +206,6 @@ div.time-input { display: inline-block; width: 10px; height: 10px; - margin: 0 5px 0 0; } .add-panel-btn { diff --git a/web/ui/react-app/src/DataTable.tsx b/web/ui/react-app/src/DataTable.tsx index 877a60228f..101431fecc 100644 --- a/web/ui/react-app/src/DataTable.tsx +++ b/web/ui/react-app/src/DataTable.tsx @@ -3,6 +3,7 @@ import React, { FC, ReactNode } from 'react'; import { Alert, Table } from 'reactstrap'; import SeriesName from './SeriesName'; +import { Metric } from './types/types'; export interface QueryResult { data: @@ -35,10 +36,6 @@ interface RangeSamples { values: SampleValue[]; } -interface Metric { - [key: string]: string; -} - type SampleValue = [number, string]; const limitSeries = (series: S[]): S[] => { diff --git a/web/ui/react-app/src/Graph.test.tsx b/web/ui/react-app/src/Graph.test.tsx index 3fece9469b..ef410cf59d 100644 --- a/web/ui/react-app/src/Graph.test.tsx +++ b/web/ui/react-app/src/Graph.test.tsx @@ -3,48 +3,49 @@ import { shallow } from 'enzyme'; import Graph from './Graph'; import { Alert } from 'reactstrap'; import ReactResizeDetector from 'react-resize-detector'; -import Legend from './Legend'; describe('Graph', () => { - [ - { - data: null, - color: 'light', - children: 'No data queried yet', - }, - { - data: { resultType: 'invalid' }, + 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', - }, - ].forEach(testCase => { - it(`renders an alert if data is "${testCase.data}"`, () => { - const props = { - data: testCase.data, - stacked: false, - queryParams: { - startTime: 1572100210000, - endTime: 1572100217898, - resolution: 10, - }, - }; - const graph = shallow(); - const alert = graph.find(Alert); - expect(alert.prop('color')).toEqual(testCase.color); - expect(alert.childAt(0).text()).toEqual(testCase.children); - }); + 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); }); describe('data is returned', () => { - const props = { + const props: any = { queryParams: { startTime: 1572128592, endTime: 1572130692, @@ -95,14 +96,12 @@ describe('Graph', () => { const div = graph.find('div').filterWhere(elem => elem.prop('className') === 'graph'); const resize = div.find(ReactResizeDetector); const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart'); - const legend = graph.find(Legend); expect(resize.prop('handleWidth')).toBe(true); expect(div).toHaveLength(1); expect(innerdiv).toHaveLength(1); - expect(legend).toHaveLength(1); }); it('formats tick values correctly', () => { - const graph = new Graph(); + const graph = new Graph({ data: { result: [] }, queryParams: {} } as any); [ { input: 2e24, output: '2.00Y' }, { input: 2e23, output: '200.00Z' }, @@ -156,9 +155,15 @@ describe('Graph', () => { { input: 2e-24, output: '2.00y' }, { input: 2e-25, output: '0.20y' }, { input: 2e-26, output: '0.02y' }, - ].map(function(t) { + ].map(t => { expect(graph.formatValue(t.input)).toBe(t.output); }); }); + describe('Legend', () => { + it('renders a legend', () => { + const graph = shallow(); + expect(graph.find('.graph-legend .legend-item')).toHaveLength(1); + }); + }); }); }); diff --git a/web/ui/react-app/src/Graph.tsx b/web/ui/react-app/src/Graph.tsx index 1cd4c7212b..6b7b4d4d1b 100644 --- a/web/ui/react-app/src/Graph.tsx +++ b/web/ui/react-app/src/Graph.tsx @@ -3,9 +3,9 @@ import React, { PureComponent } from 'react'; import ReactResizeDetector from 'react-resize-detector'; import { Alert } from 'reactstrap'; -import Legend from './Legend'; import { escapeHTML } from './utils/html'; - +import SeriesName from './SeriesName'; +import { Metric, QueryParams } from './types/types'; require('flot'); require('flot/source/jquery.flot.crosshair'); require('flot/source/jquery.flot.legend'); @@ -13,84 +13,84 @@ require('flot/source/jquery.flot.time'); require('flot/source/jquery.canvaswrapper'); require('jquery.flot.tooltip'); -let graphID = 0; -function getGraphID() { - // TODO: This is ugly. - return graphID++; -} - interface GraphProps { - data: any; // TODO: Type this. + data: { + resultType: string; + result: Array<{ metric: Metric; values: [number, string][] }>; + }; stacked: boolean; - queryParams: { - startTime: number; - endTime: number; - resolution: number; - } | null; + queryParams: QueryParams | null; } -class Graph extends PureComponent { - private id: number = getGraphID(); +export interface GraphSeries { + labels: { [key: string]: string }; + color: string; + normalizedColor: string; + data: (number | null)[][]; // [x,y][] + index: number; +} + +interface GraphState { + selectedSeriesIndex: number | null; + hoveredSeriesIndex: number | null; +} + +class Graph extends PureComponent { private chartRef = React.createRef(); - renderLabels(labels: { [key: string]: string }) { - const labelStrings: string[] = []; - for (const label in labels) { - if (label !== '__name__') { - labelStrings.push('' + label + ': ' + escapeHTML(labels[label])); - } - } - return '
' + labelStrings.join('
') + '
'; - } + state = { + selectedSeriesIndex: null, + hoveredSeriesIndex: null, + }; formatValue = (y: number | null): string => { if (y === null) { return 'null'; } - const abs_y = Math.abs(y); - if (abs_y >= 1e24) { + const absY = Math.abs(y); + if (absY >= 1e24) { return (y / 1e24).toFixed(2) + 'Y'; - } else if (abs_y >= 1e21) { + } else if (absY >= 1e21) { return (y / 1e21).toFixed(2) + 'Z'; - } else if (abs_y >= 1e18) { + } else if (absY >= 1e18) { return (y / 1e18).toFixed(2) + 'E'; - } else if (abs_y >= 1e15) { + } else if (absY >= 1e15) { return (y / 1e15).toFixed(2) + 'P'; - } else if (abs_y >= 1e12) { + } else if (absY >= 1e12) { return (y / 1e12).toFixed(2) + 'T'; - } else if (abs_y >= 1e9) { + } else if (absY >= 1e9) { return (y / 1e9).toFixed(2) + 'G'; - } else if (abs_y >= 1e6) { + } else if (absY >= 1e6) { return (y / 1e6).toFixed(2) + 'M'; - } else if (abs_y >= 1e3) { + } else if (absY >= 1e3) { return (y / 1e3).toFixed(2) + 'k'; - } else if (abs_y >= 1) { + } else if (absY >= 1) { return y.toFixed(2); - } else if (abs_y === 0) { + } else if (absY === 0) { return y.toFixed(2); - } else if (abs_y < 1e-23) { + } else if (absY < 1e-23) { return (y / 1e-24).toFixed(2) + 'y'; - } else if (abs_y < 1e-20) { + } else if (absY < 1e-20) { return (y / 1e-21).toFixed(2) + 'z'; - } else if (abs_y < 1e-17) { + } else if (absY < 1e-17) { return (y / 1e-18).toFixed(2) + 'a'; - } else if (abs_y < 1e-14) { + } else if (absY < 1e-14) { return (y / 1e-15).toFixed(2) + 'f'; - } else if (abs_y < 1e-11) { + } else if (absY < 1e-11) { return (y / 1e-12).toFixed(2) + 'p'; - } else if (abs_y < 1e-8) { + } else if (absY < 1e-8) { return (y / 1e-9).toFixed(2) + 'n'; - } else if (abs_y < 1e-5) { + } else if (absY < 1e-5) { return (y / 1e-6).toFixed(2) + 'ยต'; - } else if (abs_y < 1e-2) { + } else if (absY < 1e-2) { return (y / 1e-3).toFixed(2) + 'm'; - } else if (abs_y <= 1) { + } else if (absY <= 1) { return y.toFixed(2); } throw Error("couldn't format a value, this is a bug"); }; - getOptions(): any { + getOptions(): jquery.flot.plotOptions { return { grid: { hoverable: true, @@ -117,12 +117,22 @@ class Graph extends PureComponent { tooltip: { show: true, cssClass: 'graph-tooltip', - content: (label: string, xval: number, yval: number, flotItem: any) => { - const series = flotItem.series; // TODO: type this. - const date = '' + new Date(xval).toUTCString() + ''; - const swatch = ''; - const content = swatch + (series.labels.__name__ || 'value') + ': ' + yval + ''; - return date + '
' + content + '
' + this.renderLabels(series.labels); + 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, @@ -141,15 +151,10 @@ class Graph extends PureComponent { // This was adapted from Flot's color generation code. getColors() { - const colors = []; const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed']; const colorPoolSize = colorPool.length; let variation = 0; - const neededColors = this.props.data.result.length; - - for (let i = 0; i < neededColors; i++) { - const c = ($ as any).color.parse(colorPool[i % colorPoolSize] || '#666'); - + 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 @@ -160,45 +165,46 @@ class Graph extends PureComponent { if (i % colorPoolSize === 0 && i) { if (variation >= 0) { - if (variation < 0.5) { - variation = -variation - 0.2; - } else variation = 0; - } else variation = -variation; + variation = variation < 0.5 ? -variation - 0.2 : 0; + } else { + variation = -variation; + } } - - colors[i] = c.scale('rgb', 1 + variation); - } - - return colors; + return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation); + }); } - getData() { + getData(): GraphSeries[] { const colors = this.getColors(); - - return this.props.data.result.map((ts: any /* TODO: Type this*/, index: number) => { + const { hoveredSeriesIndex } = this.state; + const { stacked, queryParams } = this.props; + const { startTime, endTime, resolution } = queryParams!; + return this.props.data.result.map((ts, index) => { // Insert nulls for all missing steps. const data = []; let pos = 0; - const params = this.props.queryParams!; - for (let t = params.startTime; t <= params.endTime; t += params.resolution) { + for (let t = startTime; t <= endTime; t += resolution) { // Allow for floating point inaccuracy. - if (ts.values.length > pos && ts.values[pos][0] < t + params.resolution / 100) { - data.push([ts.values[pos][0] * 1000, this.parseValue(ts.values[pos][1])]); + const currentValue = ts.values[pos]; + if (ts.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, this.props.stacked ? 0 : null]); + data.push([t * 1000, stacked ? 0 : null]); } } + const { r, g, b } = colors[index]; return { labels: ts.metric !== null ? ts.metric : {}, - data: data, - color: colors[index], - index: index, + color: `rgba(${r}, ${g}, ${b}, ${hoveredSeriesIndex === null || hoveredSeriesIndex === index ? 1 : 0.3})`, + normalizedColor: `rgb(${r}, ${g}, ${b}`, + data, + index, }; }); } @@ -217,11 +223,10 @@ class Graph extends PureComponent { return val; } - componentDidMount() { - this.plot(); - } - - componentDidUpdate() { + componentDidUpdate(prevProps: GraphProps) { + if (prevProps.data !== this.props.data) { + this.setState({ selectedSeriesIndex: null }); + } this.plot(); } @@ -229,13 +234,14 @@ class Graph extends PureComponent { this.destroyPlot(); } - plot() { - if (this.chartRef.current === null) { + plot = () => { + if (!this.chartRef.current) { return; } + const selectedData = this.getData()[this.state.selectedSeriesIndex!]; this.destroyPlot(); - $.plot($(this.chartRef.current!), this.getData(), this.getOptions()); - } + $.plot($(this.chartRef.current), selectedData ? [selectedData] : this.getData(), this.getOptions()); + }; destroyPlot() { const chart = $(this.chartRef.current!).data('plot'); @@ -244,6 +250,17 @@ class Graph extends PureComponent { } } + handleSeriesSelect = (index: number) => () => { + const { selectedSeriesIndex } = this.state; + this.setState({ selectedSeriesIndex: selectedSeriesIndex !== index ? index : null }); + }; + + handleSeriesHover = (index: number) => () => { + this.setState({ hoveredSeriesIndex: index }); + }; + + handleLegendMouseOut = () => this.setState({ hoveredSeriesIndex: null }); + render() { if (this.props.data === null) { return No data queried yet; @@ -261,11 +278,28 @@ class Graph extends PureComponent { return Empty query result; } + const { selectedSeriesIndex } = this.state; + const series = this.getData(); + const canUseHover = series.length > 1 && selectedSeriesIndex === null; + return (
- this.plot()} /> +
- +
+ {series.map(({ index, normalizedColor, labels }) => ( +
1 ? this.handleSeriesSelect(index) : undefined} + onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined} + key={index} + className="legend-item" + > + + +
+ ))} +
); } diff --git a/web/ui/react-app/src/Legend.test.tsx b/web/ui/react-app/src/Legend.test.tsx deleted file mode 100755 index f61dc4d1cd..0000000000 --- a/web/ui/react-app/src/Legend.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import * as React from 'react'; -import { shallow } from 'enzyme'; -import Legend from './Legend'; -import SeriesName from './SeriesName'; - -describe('Legend', () => { - describe('regardless of series', () => { - it('renders a table', () => { - const legend = shallow(); - expect(legend.type()).toEqual('table'); - expect(legend.prop('className')).toEqual('graph-legend'); - const tbody = legend.children(); - expect(tbody.type()).toEqual('tbody'); - }); - }); - describe('when series is empty', () => { - it('renders props as empty legend table', () => { - const legend = shallow(); - const tbody = legend.children(); - expect(tbody.children()).toHaveLength(0); - }); - }); - - describe('when series has one element', () => { - const legendProps = { - series: [ - { - index: 1, - color: 'red', - labels: { - __name__: 'metric_name', - label1: 'value_1', - labeln: 'value_n', - }, - }, - ], - }; - it('renders a row of the one series', () => { - const legend = shallow(); - const tbody = legend.children(); - expect(tbody.children()).toHaveLength(1); - const row = tbody.find('tr'); - expect(row.prop('className')).toEqual('legend-item'); - }); - it('renders a legend swatch', () => { - const legend = shallow(); - const tbody = legend.children(); - const row = tbody.find('tr'); - const swatch = row.childAt(0); - expect(swatch.type()).toEqual('td'); - expect(swatch.children().prop('className')).toEqual('legend-swatch'); - expect(swatch.children().prop('style')).toEqual({ - backgroundColor: 'red', - }); - }); - it('renders a series name', () => { - const legend = shallow(); - const tbody = legend.children(); - const row = tbody.find('tr'); - const series = row.childAt(1); - expect(series.type()).toEqual('td'); - const seriesName = series.find(SeriesName); - expect(seriesName).toHaveLength(1); - expect(seriesName.prop('labels')).toEqual(legendProps.series[0].labels); - expect(seriesName.prop('format')).toBe(true); - }); - }); - - describe('when series has _n_ elements', () => { - const range = Array.from(Array(20).keys()); - const legendProps = { - series: range.map(i => ({ - index: i, - color: 'red', - labels: { - __name__: `metric_name_${i}`, - label1: 'value_1', - labeln: 'value_n', - }, - })), - }; - it('renders _n_ rows', () => { - const legend = shallow(); - const tbody = legend.children(); - expect(tbody.children()).toHaveLength(20); - const rows = tbody.find('tr'); - rows.forEach(row => { - expect(row.prop('className')).toEqual('legend-item'); - expect(row.find(SeriesName)).toHaveLength(1); - }); - }); - }); -}); diff --git a/web/ui/react-app/src/Legend.tsx b/web/ui/react-app/src/Legend.tsx deleted file mode 100644 index b0f60c9e14..0000000000 --- a/web/ui/react-app/src/Legend.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { FC } from 'react'; - -import SeriesName from './SeriesName'; - -interface LegendProps { - series: any; // TODO: Type this. -} - -const Legend: FC = ({ series }) => { - return ( - - - {series.map((s: any) => ( - - - - - ))} - -
-
-
- -
- ); -}; - -export default Legend; diff --git a/web/ui/react-app/src/Panel.test.tsx b/web/ui/react-app/src/Panel.test.tsx index 9dbcde1efa..f44f65acb6 100644 --- a/web/ui/react-app/src/Panel.test.tsx +++ b/web/ui/react-app/src/Panel.test.tsx @@ -83,11 +83,12 @@ describe('Panel', () => { }; const graphPanel = mount(); const controls = graphPanel.find(GraphControls); + graphPanel.setState({ data: [] }); const graph = graphPanel.find(Graph); - expect(controls.prop('endTime')).toEqual(props.options.endTime); - expect(controls.prop('range')).toEqual(props.options.range); - expect(controls.prop('resolution')).toEqual(props.options.resolution); - expect(controls.prop('stacked')).toEqual(props.options.stacked); - expect(graph.prop('stacked')).toEqual(props.options.stacked); + 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); }); }); diff --git a/web/ui/react-app/src/Panel.tsx b/web/ui/react-app/src/Panel.tsx index 2c6299ff6d..9422b59d9f 100644 --- a/web/ui/react-app/src/Panel.tsx +++ b/web/ui/react-app/src/Panel.tsx @@ -11,6 +11,7 @@ import DataTable from './DataTable'; import TimeInput from './TimeInput'; import QueryStatsView, { QueryStats } from './QueryStatsView'; import PathPrefixProps from './PathPrefixProps'; +import { QueryParams } from './types/types'; interface PanelProps { options: PanelOptions; @@ -23,12 +24,7 @@ interface PanelProps { interface PanelState { data: any; // TODO: Type data. - lastQueryParams: { - // TODO: Share these with Graph.tsx in a file. - startTime: number; - endTime: number; - resolution: number; - } | null; + lastQueryParams: QueryParams | null; loading: boolean; error: string | null; stats: QueryStats | null; @@ -291,7 +287,11 @@ class Panel extends Component { onChangeResolution={this.handleChangeResolution} onChangeStacking={this.handleChangeStacking} /> - + {this.state.data ? ( + + ) : ( + No data queried yet + )} )} diff --git a/web/ui/react-app/src/pages/targets/__testdata__/testdata.ts b/web/ui/react-app/src/pages/targets/__testdata__/testdata.ts index e7cb3de1d6..4925b28752 100644 --- a/web/ui/react-app/src/pages/targets/__testdata__/testdata.ts +++ b/web/ui/react-app/src/pages/targets/__testdata__/testdata.ts @@ -1,6 +1,6 @@ /* eslint @typescript-eslint/camelcase: 0 */ -import { ScrapePools, Target, Labels } from '../target'; +import { ScrapePools } from '../target'; export const targetGroups: ScrapePools = Object.freeze({ blackbox: { diff --git a/web/ui/react-app/src/pages/targets/target.test.ts b/web/ui/react-app/src/pages/targets/target.test.ts index 613ec97792..ca56979f8e 100644 --- a/web/ui/react-app/src/pages/targets/target.test.ts +++ b/web/ui/react-app/src/pages/targets/target.test.ts @@ -2,7 +2,6 @@ import { sampleApiResponse } from './__testdata__/testdata'; import { groupTargets, Target, ScrapePools, getColor } from './target'; -import { string } from 'prop-types'; describe('groupTargets', () => { const targets: Target[] = sampleApiResponse.data.activeTargets as Target[]; diff --git a/web/ui/react-app/src/types/index.d.ts b/web/ui/react-app/src/types/index.d.ts new file mode 100644 index 0000000000..41aea3f21f --- /dev/null +++ b/web/ui/react-app/src/types/index.d.ts @@ -0,0 +1,64 @@ +declare namespace jquery.flot { + // eslint-disable-next-line @typescript-eslint/class-name-casing + interface plotOptions extends jquery.flot.plotOptions { + tooltip: { + show?: boolean; + cssClass?: string; + content: ( + label: string, + xval: number, + yval: number, + flotItem: jquery.flot.item & { + series: { + labels: { [key: string]: string }; + color: string; + data: (number | null)[][]; // [x,y][] + index: number; + }; + } + ) => string | string; + xDateFormat?: string; + yDateFormat?: string; + monthNames?: string; + dayNames?: string; + shifts?: { + x: number; + y: number; + }; + defaultTheme?: boolean; + lines?: boolean; + onHover?: () => string; + $compat?: boolean; + }; + crosshair: Partial; + xaxis: { [K in keyof jquery.flot.axisOptions]: jquery.flot.axisOptions[K] } & { + showTicks: boolean; + showMinorTicks: boolean; + timeBase: 'milliseconds'; + }; + series: { [K in keyof jquery.flot.seriesOptions]: jq.flot.seriesOptions[K] } & { + stack: boolean; + }; + } +} + +interface Color { + r: number; + g: number; + b: number; + a: number; + add: (c: string, d: number) => Color; + scale: (c: string, f: number) => Color; + toString: () => string; + normalize: () => Color; + clone: () => Color; +} + +interface JQueryStatic { + color: { + extract: (el: JQuery, css?: CSSStyleDeclaration) => Color; + make: (r?: number, g?: number, b?: number, a?: number) => Color; + parse: (c: string) => Color; + scale: () => Color; + }; +} diff --git a/web/ui/react-app/src/types/types.ts b/web/ui/react-app/src/types/types.ts new file mode 100644 index 0000000000..11829edefb --- /dev/null +++ b/web/ui/react-app/src/types/types.ts @@ -0,0 +1,9 @@ +export interface Metric { + [key: string]: string; +} + +export interface QueryParams { + startTime: number; + endTime: number; + resolution: number; +}