mirror of
https://github.com/prometheus/prometheus.git
synced 2025-03-05 20:59:13 -08:00
React UI: Graph legend (#6321)
* initial commit Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * eslint fixes Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * hover bug fix Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * refactoring Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * remove unnecessary check Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * fix tests Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * lint fix https://github.com/prometheus/prometheus/issues/6268 Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * fix typos Fixes<https://github.com/prometheus/prometheus/issues/6268> Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * init hover events if can Fixes: <https://github.com/prometheus/prometheus/issues/6268> Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * review changes Signed-off-by: blalov <boyko.lalov@tick42.com> Signed-off-by: Boyko Lalov <boyskila@gmail.com> * fix activeIndex bug Signed-off-by: Boyko Lalov <boyskila@gmail.com> * extend plot options types Signed-off-by: Boyko Lalov <boyskila@gmail.com> * adding more types Signed-off-by: blalov <boyko.lalov@tick42.com> * fix branch after wrong force push Signed-off-by: blalov <boyko.lalov@tick42.com> * unit test fixes Signed-off-by: blalov <boyko.lalov@tick42.com> * remove unused variables Signed-off-by: blalov <boyko.lalov@tick42.com>
This commit is contained in:
parent
cb92a45bf3
commit
731ca08acd
|
@ -135,16 +135,31 @@ div.time-input {
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-legend {
|
.graph-legend {
|
||||||
margin: 15px 0 15px 25px;
|
margin: 15px 0 15px 55px;
|
||||||
font-size: 0.8em;
|
font-size: 0.75em;
|
||||||
|
padding: 10px 5px;
|
||||||
|
display: inline-block;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-legend .legend-swatch {
|
.legend-item {
|
||||||
padding: 5px;
|
display: flex;
|
||||||
height: 5px;
|
align-items: center;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-swatch {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
outline: 1.5px solid #ccc;
|
outline: 1.5px solid #ccc;
|
||||||
margin: 2px 8px 2px 0;
|
margin: 2px 8px 2px 0;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-metric-name {
|
.legend-metric-name {
|
||||||
|
@ -191,7 +206,6 @@ div.time-input {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
margin: 0 5px 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-panel-btn {
|
.add-panel-btn {
|
||||||
|
|
|
@ -3,6 +3,7 @@ import React, { FC, ReactNode } from 'react';
|
||||||
import { Alert, Table } from 'reactstrap';
|
import { Alert, Table } from 'reactstrap';
|
||||||
|
|
||||||
import SeriesName from './SeriesName';
|
import SeriesName from './SeriesName';
|
||||||
|
import { Metric } from './types/types';
|
||||||
|
|
||||||
export interface QueryResult {
|
export interface QueryResult {
|
||||||
data:
|
data:
|
||||||
|
@ -35,10 +36,6 @@ interface RangeSamples {
|
||||||
values: SampleValue[];
|
values: SampleValue[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Metric {
|
|
||||||
[key: string]: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SampleValue = [number, string];
|
type SampleValue = [number, string];
|
||||||
|
|
||||||
const limitSeries = <S extends InstantSample | RangeSamples>(series: S[]): S[] => {
|
const limitSeries = <S extends InstantSample | RangeSamples>(series: S[]): S[] => {
|
||||||
|
|
|
@ -3,48 +3,49 @@ import { shallow } from 'enzyme';
|
||||||
import Graph from './Graph';
|
import Graph from './Graph';
|
||||||
import { Alert } from 'reactstrap';
|
import { Alert } from 'reactstrap';
|
||||||
import ReactResizeDetector from 'react-resize-detector';
|
import ReactResizeDetector from 'react-resize-detector';
|
||||||
import Legend from './Legend';
|
|
||||||
|
|
||||||
describe('Graph', () => {
|
describe('Graph', () => {
|
||||||
[
|
it('renders an alert if data result type is different than "matrix"', () => {
|
||||||
{
|
const props: any = {
|
||||||
data: null,
|
data: { resultType: 'invalid', result: [] },
|
||||||
color: 'light',
|
stacked: false,
|
||||||
children: 'No data queried yet',
|
queryParams: {
|
||||||
},
|
startTime: 1572100210000,
|
||||||
{
|
endTime: 1572100217898,
|
||||||
data: { resultType: 'invalid' },
|
resolution: 10,
|
||||||
|
},
|
||||||
color: 'danger',
|
color: 'danger',
|
||||||
children: `Query result is of wrong type '`,
|
children: `Query result is of wrong type '`,
|
||||||
},
|
};
|
||||||
{
|
const graph = shallow(<Graph {...props} />);
|
||||||
|
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: {
|
data: {
|
||||||
resultType: 'matrix',
|
resultType: 'matrix',
|
||||||
result: [],
|
result: [],
|
||||||
},
|
},
|
||||||
color: 'secondary',
|
color: 'secondary',
|
||||||
children: 'Empty query result',
|
children: 'Empty query result',
|
||||||
},
|
stacked: false,
|
||||||
].forEach(testCase => {
|
queryParams: {
|
||||||
it(`renders an alert if data is "${testCase.data}"`, () => {
|
startTime: 1572100210000,
|
||||||
const props = {
|
endTime: 1572100217898,
|
||||||
data: testCase.data,
|
resolution: 10,
|
||||||
stacked: false,
|
},
|
||||||
queryParams: {
|
};
|
||||||
startTime: 1572100210000,
|
const graph = shallow(<Graph {...props} />);
|
||||||
endTime: 1572100217898,
|
const alert = graph.find(Alert);
|
||||||
resolution: 10,
|
expect(alert.prop('color')).toEqual(props.color);
|
||||||
},
|
expect(alert.childAt(0).text()).toEqual(props.children);
|
||||||
};
|
|
||||||
const graph = shallow(<Graph {...props} />);
|
|
||||||
const alert = graph.find(Alert);
|
|
||||||
expect(alert.prop('color')).toEqual(testCase.color);
|
|
||||||
expect(alert.childAt(0).text()).toEqual(testCase.children);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('data is returned', () => {
|
describe('data is returned', () => {
|
||||||
const props = {
|
const props: any = {
|
||||||
queryParams: {
|
queryParams: {
|
||||||
startTime: 1572128592,
|
startTime: 1572128592,
|
||||||
endTime: 1572130692,
|
endTime: 1572130692,
|
||||||
|
@ -95,14 +96,12 @@ describe('Graph', () => {
|
||||||
const div = graph.find('div').filterWhere(elem => elem.prop('className') === 'graph');
|
const div = graph.find('div').filterWhere(elem => elem.prop('className') === 'graph');
|
||||||
const resize = div.find(ReactResizeDetector);
|
const resize = div.find(ReactResizeDetector);
|
||||||
const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart');
|
const innerdiv = div.find('div').filterWhere(elem => elem.prop('className') === 'graph-chart');
|
||||||
const legend = graph.find(Legend);
|
|
||||||
expect(resize.prop('handleWidth')).toBe(true);
|
expect(resize.prop('handleWidth')).toBe(true);
|
||||||
expect(div).toHaveLength(1);
|
expect(div).toHaveLength(1);
|
||||||
expect(innerdiv).toHaveLength(1);
|
expect(innerdiv).toHaveLength(1);
|
||||||
expect(legend).toHaveLength(1);
|
|
||||||
});
|
});
|
||||||
it('formats tick values correctly', () => {
|
it('formats tick values correctly', () => {
|
||||||
const graph = new Graph();
|
const graph = new Graph({ data: { result: [] }, queryParams: {} } as any);
|
||||||
[
|
[
|
||||||
{ input: 2e24, output: '2.00Y' },
|
{ input: 2e24, output: '2.00Y' },
|
||||||
{ input: 2e23, output: '200.00Z' },
|
{ input: 2e23, output: '200.00Z' },
|
||||||
|
@ -156,9 +155,15 @@ describe('Graph', () => {
|
||||||
{ input: 2e-24, output: '2.00y' },
|
{ input: 2e-24, output: '2.00y' },
|
||||||
{ input: 2e-25, output: '0.20y' },
|
{ input: 2e-25, output: '0.20y' },
|
||||||
{ input: 2e-26, output: '0.02y' },
|
{ input: 2e-26, output: '0.02y' },
|
||||||
].map(function(t) {
|
].map(t => {
|
||||||
expect(graph.formatValue(t.input)).toBe(t.output);
|
expect(graph.formatValue(t.input)).toBe(t.output);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe('Legend', () => {
|
||||||
|
it('renders a legend', () => {
|
||||||
|
const graph = shallow(<Graph {...props} />);
|
||||||
|
expect(graph.find('.graph-legend .legend-item')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -3,9 +3,9 @@ import React, { PureComponent } from 'react';
|
||||||
import ReactResizeDetector from 'react-resize-detector';
|
import ReactResizeDetector from 'react-resize-detector';
|
||||||
import { Alert } from 'reactstrap';
|
import { Alert } from 'reactstrap';
|
||||||
|
|
||||||
import Legend from './Legend';
|
|
||||||
import { escapeHTML } from './utils/html';
|
import { escapeHTML } from './utils/html';
|
||||||
|
import SeriesName from './SeriesName';
|
||||||
|
import { Metric, QueryParams } from './types/types';
|
||||||
require('flot');
|
require('flot');
|
||||||
require('flot/source/jquery.flot.crosshair');
|
require('flot/source/jquery.flot.crosshair');
|
||||||
require('flot/source/jquery.flot.legend');
|
require('flot/source/jquery.flot.legend');
|
||||||
|
@ -13,84 +13,84 @@ require('flot/source/jquery.flot.time');
|
||||||
require('flot/source/jquery.canvaswrapper');
|
require('flot/source/jquery.canvaswrapper');
|
||||||
require('jquery.flot.tooltip');
|
require('jquery.flot.tooltip');
|
||||||
|
|
||||||
let graphID = 0;
|
|
||||||
function getGraphID() {
|
|
||||||
// TODO: This is ugly.
|
|
||||||
return graphID++;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GraphProps {
|
interface GraphProps {
|
||||||
data: any; // TODO: Type this.
|
data: {
|
||||||
|
resultType: string;
|
||||||
|
result: Array<{ metric: Metric; values: [number, string][] }>;
|
||||||
|
};
|
||||||
stacked: boolean;
|
stacked: boolean;
|
||||||
queryParams: {
|
queryParams: QueryParams | null;
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
resolution: number;
|
|
||||||
} | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Graph extends PureComponent<GraphProps> {
|
export interface GraphSeries {
|
||||||
private id: number = getGraphID();
|
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<GraphProps, GraphState> {
|
||||||
private chartRef = React.createRef<HTMLDivElement>();
|
private chartRef = React.createRef<HTMLDivElement>();
|
||||||
|
|
||||||
renderLabels(labels: { [key: string]: string }) {
|
state = {
|
||||||
const labelStrings: string[] = [];
|
selectedSeriesIndex: null,
|
||||||
for (const label in labels) {
|
hoveredSeriesIndex: null,
|
||||||
if (label !== '__name__') {
|
};
|
||||||
labelStrings.push('<strong>' + label + '</strong>: ' + escapeHTML(labels[label]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
formatValue = (y: number | null): string => {
|
formatValue = (y: number | null): string => {
|
||||||
if (y === null) {
|
if (y === null) {
|
||||||
return 'null';
|
return 'null';
|
||||||
}
|
}
|
||||||
const abs_y = Math.abs(y);
|
const absY = Math.abs(y);
|
||||||
if (abs_y >= 1e24) {
|
if (absY >= 1e24) {
|
||||||
return (y / 1e24).toFixed(2) + 'Y';
|
return (y / 1e24).toFixed(2) + 'Y';
|
||||||
} else if (abs_y >= 1e21) {
|
} else if (absY >= 1e21) {
|
||||||
return (y / 1e21).toFixed(2) + 'Z';
|
return (y / 1e21).toFixed(2) + 'Z';
|
||||||
} else if (abs_y >= 1e18) {
|
} else if (absY >= 1e18) {
|
||||||
return (y / 1e18).toFixed(2) + 'E';
|
return (y / 1e18).toFixed(2) + 'E';
|
||||||
} else if (abs_y >= 1e15) {
|
} else if (absY >= 1e15) {
|
||||||
return (y / 1e15).toFixed(2) + 'P';
|
return (y / 1e15).toFixed(2) + 'P';
|
||||||
} else if (abs_y >= 1e12) {
|
} else if (absY >= 1e12) {
|
||||||
return (y / 1e12).toFixed(2) + 'T';
|
return (y / 1e12).toFixed(2) + 'T';
|
||||||
} else if (abs_y >= 1e9) {
|
} else if (absY >= 1e9) {
|
||||||
return (y / 1e9).toFixed(2) + 'G';
|
return (y / 1e9).toFixed(2) + 'G';
|
||||||
} else if (abs_y >= 1e6) {
|
} else if (absY >= 1e6) {
|
||||||
return (y / 1e6).toFixed(2) + 'M';
|
return (y / 1e6).toFixed(2) + 'M';
|
||||||
} else if (abs_y >= 1e3) {
|
} else if (absY >= 1e3) {
|
||||||
return (y / 1e3).toFixed(2) + 'k';
|
return (y / 1e3).toFixed(2) + 'k';
|
||||||
} else if (abs_y >= 1) {
|
} else if (absY >= 1) {
|
||||||
return y.toFixed(2);
|
return y.toFixed(2);
|
||||||
} else if (abs_y === 0) {
|
} else if (absY === 0) {
|
||||||
return y.toFixed(2);
|
return y.toFixed(2);
|
||||||
} else if (abs_y < 1e-23) {
|
} else if (absY < 1e-23) {
|
||||||
return (y / 1e-24).toFixed(2) + 'y';
|
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';
|
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';
|
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';
|
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';
|
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';
|
return (y / 1e-9).toFixed(2) + 'n';
|
||||||
} else if (abs_y < 1e-5) {
|
} else if (absY < 1e-5) {
|
||||||
return (y / 1e-6).toFixed(2) + 'µ';
|
return (y / 1e-6).toFixed(2) + 'µ';
|
||||||
} else if (abs_y < 1e-2) {
|
} else if (absY < 1e-2) {
|
||||||
return (y / 1e-3).toFixed(2) + 'm';
|
return (y / 1e-3).toFixed(2) + 'm';
|
||||||
} else if (abs_y <= 1) {
|
} else if (absY <= 1) {
|
||||||
return y.toFixed(2);
|
return y.toFixed(2);
|
||||||
}
|
}
|
||||||
throw Error("couldn't format a value, this is a bug");
|
throw Error("couldn't format a value, this is a bug");
|
||||||
};
|
};
|
||||||
|
|
||||||
getOptions(): any {
|
getOptions(): jquery.flot.plotOptions {
|
||||||
return {
|
return {
|
||||||
grid: {
|
grid: {
|
||||||
hoverable: true,
|
hoverable: true,
|
||||||
|
@ -117,12 +117,22 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
tooltip: {
|
tooltip: {
|
||||||
show: true,
|
show: true,
|
||||||
cssClass: 'graph-tooltip',
|
cssClass: 'graph-tooltip',
|
||||||
content: (label: string, xval: number, yval: number, flotItem: any) => {
|
content: (_, xval, yval, { series }): string => {
|
||||||
const series = flotItem.series; // TODO: type this.
|
const { labels, color } = series;
|
||||||
const date = '<span class="date">' + new Date(xval).toUTCString() + '</span>';
|
return `
|
||||||
const swatch = '<span class="detail-swatch" style="background-color: ' + series.color + '"></span>';
|
<div class="date">${new Date(xval).toUTCString()}</div>
|
||||||
const content = swatch + (series.labels.__name__ || 'value') + ': <strong>' + yval + '</strong>';
|
<div>
|
||||||
return date + '<br>' + content + '<br>' + this.renderLabels(series.labels);
|
<span class="detail-swatch" style="background-color: ${color}" />
|
||||||
|
<span>${labels.__name__ || 'value'}: <strong>${yval}</strong></span>
|
||||||
|
<div>
|
||||||
|
<div class="labels mt-1">
|
||||||
|
${Object.keys(labels)
|
||||||
|
.map(k =>
|
||||||
|
k !== '__name__' ? `<div class="mb-1"><strong>${k}</strong>: ${escapeHTML(labels[k])}</div>` : ''
|
||||||
|
)
|
||||||
|
.join('')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
},
|
},
|
||||||
defaultTheme: false,
|
defaultTheme: false,
|
||||||
lines: true,
|
lines: true,
|
||||||
|
@ -141,15 +151,10 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
|
|
||||||
// This was adapted from Flot's color generation code.
|
// This was adapted from Flot's color generation code.
|
||||||
getColors() {
|
getColors() {
|
||||||
const colors = [];
|
|
||||||
const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
|
const colorPool = ['#edc240', '#afd8f8', '#cb4b4b', '#4da74d', '#9440ed'];
|
||||||
const colorPoolSize = colorPool.length;
|
const colorPoolSize = colorPool.length;
|
||||||
let variation = 0;
|
let variation = 0;
|
||||||
const neededColors = this.props.data.result.length;
|
return this.props.data.result.map((_, i) => {
|
||||||
|
|
||||||
for (let i = 0; i < neededColors; i++) {
|
|
||||||
const c = ($ as any).color.parse(colorPool[i % colorPoolSize] || '#666');
|
|
||||||
|
|
||||||
// Each time we exhaust the colors in the pool we adjust
|
// Each time we exhaust the colors in the pool we adjust
|
||||||
// a scaling factor used to produce more variations on
|
// a scaling factor used to produce more variations on
|
||||||
// those colors. The factor alternates negative/positive
|
// those colors. The factor alternates negative/positive
|
||||||
|
@ -160,45 +165,46 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
|
|
||||||
if (i % colorPoolSize === 0 && i) {
|
if (i % colorPoolSize === 0 && i) {
|
||||||
if (variation >= 0) {
|
if (variation >= 0) {
|
||||||
if (variation < 0.5) {
|
variation = variation < 0.5 ? -variation - 0.2 : 0;
|
||||||
variation = -variation - 0.2;
|
} else {
|
||||||
} else variation = 0;
|
variation = -variation;
|
||||||
} else variation = -variation;
|
}
|
||||||
}
|
}
|
||||||
|
return $.color.parse(colorPool[i % colorPoolSize] || '#666').scale('rgb', 1 + variation);
|
||||||
colors[i] = c.scale('rgb', 1 + variation);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return colors;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getData() {
|
getData(): GraphSeries[] {
|
||||||
const colors = this.getColors();
|
const colors = this.getColors();
|
||||||
|
const { hoveredSeriesIndex } = this.state;
|
||||||
return this.props.data.result.map((ts: any /* TODO: Type this*/, index: number) => {
|
const { stacked, queryParams } = this.props;
|
||||||
|
const { startTime, endTime, resolution } = queryParams!;
|
||||||
|
return this.props.data.result.map((ts, index) => {
|
||||||
// Insert nulls for all missing steps.
|
// Insert nulls for all missing steps.
|
||||||
const data = [];
|
const data = [];
|
||||||
let pos = 0;
|
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.
|
// Allow for floating point inaccuracy.
|
||||||
if (ts.values.length > pos && ts.values[pos][0] < t + params.resolution / 100) {
|
const currentValue = ts.values[pos];
|
||||||
data.push([ts.values[pos][0] * 1000, this.parseValue(ts.values[pos][1])]);
|
if (ts.values.length > pos && currentValue[0] < t + resolution / 100) {
|
||||||
|
data.push([currentValue[0] * 1000, this.parseValue(currentValue[1])]);
|
||||||
pos++;
|
pos++;
|
||||||
} else {
|
} else {
|
||||||
// TODO: Flot has problems displaying intermittent "null" values when stacked,
|
// 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
|
// resort to 0 now. In Grafana this works for some reason, figure out how they
|
||||||
// do it.
|
// 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 {
|
return {
|
||||||
labels: ts.metric !== null ? ts.metric : {},
|
labels: ts.metric !== null ? ts.metric : {},
|
||||||
data: data,
|
color: `rgba(${r}, ${g}, ${b}, ${hoveredSeriesIndex === null || hoveredSeriesIndex === index ? 1 : 0.3})`,
|
||||||
color: colors[index],
|
normalizedColor: `rgb(${r}, ${g}, ${b}`,
|
||||||
index: index,
|
data,
|
||||||
|
index,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -217,11 +223,10 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
return val;
|
return val;
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidUpdate(prevProps: GraphProps) {
|
||||||
this.plot();
|
if (prevProps.data !== this.props.data) {
|
||||||
}
|
this.setState({ selectedSeriesIndex: null });
|
||||||
|
}
|
||||||
componentDidUpdate() {
|
|
||||||
this.plot();
|
this.plot();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -229,13 +234,14 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
this.destroyPlot();
|
this.destroyPlot();
|
||||||
}
|
}
|
||||||
|
|
||||||
plot() {
|
plot = () => {
|
||||||
if (this.chartRef.current === null) {
|
if (!this.chartRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const selectedData = this.getData()[this.state.selectedSeriesIndex!];
|
||||||
this.destroyPlot();
|
this.destroyPlot();
|
||||||
$.plot($(this.chartRef.current!), this.getData(), this.getOptions());
|
$.plot($(this.chartRef.current), selectedData ? [selectedData] : this.getData(), this.getOptions());
|
||||||
}
|
};
|
||||||
|
|
||||||
destroyPlot() {
|
destroyPlot() {
|
||||||
const chart = $(this.chartRef.current!).data('plot');
|
const chart = $(this.chartRef.current!).data('plot');
|
||||||
|
@ -244,6 +250,17 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
render() {
|
||||||
if (this.props.data === null) {
|
if (this.props.data === null) {
|
||||||
return <Alert color="light">No data queried yet</Alert>;
|
return <Alert color="light">No data queried yet</Alert>;
|
||||||
|
@ -261,11 +278,28 @@ class Graph extends PureComponent<GraphProps> {
|
||||||
return <Alert color="secondary">Empty query result</Alert>;
|
return <Alert color="secondary">Empty query result</Alert>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { selectedSeriesIndex } = this.state;
|
||||||
|
const series = this.getData();
|
||||||
|
const canUseHover = series.length > 1 && selectedSeriesIndex === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="graph">
|
<div className="graph">
|
||||||
<ReactResizeDetector handleWidth onResize={() => this.plot()} />
|
<ReactResizeDetector handleWidth onResize={this.plot} />
|
||||||
<div className="graph-chart" ref={this.chartRef} />
|
<div className="graph-chart" ref={this.chartRef} />
|
||||||
<Legend series={this.getData()} />
|
<div className="graph-legend" onMouseOut={canUseHover ? this.handleLegendMouseOut : undefined}>
|
||||||
|
{series.map(({ index, normalizedColor, labels }) => (
|
||||||
|
<div
|
||||||
|
style={{ opacity: selectedSeriesIndex !== null && index !== selectedSeriesIndex ? 0.7 : 1 }}
|
||||||
|
onClick={series.length > 1 ? this.handleSeriesSelect(index) : undefined}
|
||||||
|
onMouseOver={canUseHover ? this.handleSeriesHover(index) : undefined}
|
||||||
|
key={index}
|
||||||
|
className="legend-item"
|
||||||
|
>
|
||||||
|
<span className="legend-swatch" style={{ backgroundColor: normalizedColor }}></span>
|
||||||
|
<SeriesName labels={labels} format />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(<Legend series={[]} />);
|
|
||||||
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(<Legend series={[]} />);
|
|
||||||
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(<Legend {...legendProps} />);
|
|
||||||
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(<Legend {...legendProps} />);
|
|
||||||
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(<Legend {...legendProps} />);
|
|
||||||
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(<Legend {...legendProps} />);
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
|
@ -1,28 +0,0 @@
|
||||||
import React, { FC } from 'react';
|
|
||||||
|
|
||||||
import SeriesName from './SeriesName';
|
|
||||||
|
|
||||||
interface LegendProps {
|
|
||||||
series: any; // TODO: Type this.
|
|
||||||
}
|
|
||||||
|
|
||||||
const Legend: FC<LegendProps> = ({ series }) => {
|
|
||||||
return (
|
|
||||||
<table className="graph-legend">
|
|
||||||
<tbody>
|
|
||||||
{series.map((s: any) => (
|
|
||||||
<tr key={s.index} className="legend-item">
|
|
||||||
<td>
|
|
||||||
<div className="legend-swatch" style={{ backgroundColor: s.color }}></div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<SeriesName labels={s.labels} format={true} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Legend;
|
|
|
@ -83,11 +83,12 @@ describe('Panel', () => {
|
||||||
};
|
};
|
||||||
const graphPanel = mount(<Panel {...props} options={options} />);
|
const graphPanel = mount(<Panel {...props} options={options} />);
|
||||||
const controls = graphPanel.find(GraphControls);
|
const controls = graphPanel.find(GraphControls);
|
||||||
|
graphPanel.setState({ data: [] });
|
||||||
const graph = graphPanel.find(Graph);
|
const graph = graphPanel.find(Graph);
|
||||||
expect(controls.prop('endTime')).toEqual(props.options.endTime);
|
expect(controls.prop('endTime')).toEqual(options.endTime);
|
||||||
expect(controls.prop('range')).toEqual(props.options.range);
|
expect(controls.prop('range')).toEqual(options.range);
|
||||||
expect(controls.prop('resolution')).toEqual(props.options.resolution);
|
expect(controls.prop('resolution')).toEqual(options.resolution);
|
||||||
expect(controls.prop('stacked')).toEqual(props.options.stacked);
|
expect(controls.prop('stacked')).toEqual(options.stacked);
|
||||||
expect(graph.prop('stacked')).toEqual(props.options.stacked);
|
expect(graph.prop('stacked')).toEqual(options.stacked);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -11,6 +11,7 @@ import DataTable from './DataTable';
|
||||||
import TimeInput from './TimeInput';
|
import TimeInput from './TimeInput';
|
||||||
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
import QueryStatsView, { QueryStats } from './QueryStatsView';
|
||||||
import PathPrefixProps from './PathPrefixProps';
|
import PathPrefixProps from './PathPrefixProps';
|
||||||
|
import { QueryParams } from './types/types';
|
||||||
|
|
||||||
interface PanelProps {
|
interface PanelProps {
|
||||||
options: PanelOptions;
|
options: PanelOptions;
|
||||||
|
@ -23,12 +24,7 @@ interface PanelProps {
|
||||||
|
|
||||||
interface PanelState {
|
interface PanelState {
|
||||||
data: any; // TODO: Type data.
|
data: any; // TODO: Type data.
|
||||||
lastQueryParams: {
|
lastQueryParams: QueryParams | null;
|
||||||
// TODO: Share these with Graph.tsx in a file.
|
|
||||||
startTime: number;
|
|
||||||
endTime: number;
|
|
||||||
resolution: number;
|
|
||||||
} | null;
|
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
stats: QueryStats | null;
|
stats: QueryStats | null;
|
||||||
|
@ -291,7 +287,11 @@ class Panel extends Component<PanelProps & PathPrefixProps, PanelState> {
|
||||||
onChangeResolution={this.handleChangeResolution}
|
onChangeResolution={this.handleChangeResolution}
|
||||||
onChangeStacking={this.handleChangeStacking}
|
onChangeStacking={this.handleChangeStacking}
|
||||||
/>
|
/>
|
||||||
<Graph data={this.state.data} stacked={options.stacked} queryParams={this.state.lastQueryParams} />
|
{this.state.data ? (
|
||||||
|
<Graph data={this.state.data} stacked={options.stacked} queryParams={this.state.lastQueryParams} />
|
||||||
|
) : (
|
||||||
|
<Alert color="light">No data queried yet</Alert>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
/* eslint @typescript-eslint/camelcase: 0 */
|
/* eslint @typescript-eslint/camelcase: 0 */
|
||||||
|
|
||||||
import { ScrapePools, Target, Labels } from '../target';
|
import { ScrapePools } from '../target';
|
||||||
|
|
||||||
export const targetGroups: ScrapePools = Object.freeze({
|
export const targetGroups: ScrapePools = Object.freeze({
|
||||||
blackbox: {
|
blackbox: {
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
|
|
||||||
import { sampleApiResponse } from './__testdata__/testdata';
|
import { sampleApiResponse } from './__testdata__/testdata';
|
||||||
import { groupTargets, Target, ScrapePools, getColor } from './target';
|
import { groupTargets, Target, ScrapePools, getColor } from './target';
|
||||||
import { string } from 'prop-types';
|
|
||||||
|
|
||||||
describe('groupTargets', () => {
|
describe('groupTargets', () => {
|
||||||
const targets: Target[] = sampleApiResponse.data.activeTargets as Target[];
|
const targets: Target[] = sampleApiResponse.data.activeTargets as Target[];
|
||||||
|
|
64
web/ui/react-app/src/types/index.d.ts
vendored
Normal file
64
web/ui/react-app/src/types/index.d.ts
vendored
Normal file
|
@ -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<jquery.flot.axisOptions, 'mode' | 'color'>;
|
||||||
|
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<HTMLElement>, css?: CSSStyleDeclaration) => Color;
|
||||||
|
make: (r?: number, g?: number, b?: number, a?: number) => Color;
|
||||||
|
parse: (c: string) => Color;
|
||||||
|
scale: () => Color;
|
||||||
|
};
|
||||||
|
}
|
9
web/ui/react-app/src/types/types.ts
Normal file
9
web/ui/react-app/src/types/types.ts
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
export interface Metric {
|
||||||
|
[key: string]: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryParams {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
resolution: number;
|
||||||
|
}
|
Loading…
Reference in a new issue