mirror of
https://github.com/prometheus/prometheus.git
synced 2024-11-09 23:24:05 -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 {
|
||||
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 {
|
||||
|
|
|
@ -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 = <S extends InstantSample | RangeSamples>(series: S[]): S[] => {
|
||||
|
|
|
@ -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(<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: {
|
||||
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(<Graph {...props} />);
|
||||
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(<Graph {...props} />);
|
||||
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(<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 { 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<GraphProps> {
|
||||
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<GraphProps, GraphState> {
|
||||
private chartRef = React.createRef<HTMLDivElement>();
|
||||
|
||||
renderLabels(labels: { [key: string]: string }) {
|
||||
const labelStrings: string[] = [];
|
||||
for (const label in labels) {
|
||||
if (label !== '__name__') {
|
||||
labelStrings.push('<strong>' + label + '</strong>: ' + escapeHTML(labels[label]));
|
||||
}
|
||||
}
|
||||
return '<div class="labels">' + labelStrings.join('<br>') + '</div>';
|
||||
}
|
||||
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<GraphProps> {
|
|||
tooltip: {
|
||||
show: true,
|
||||
cssClass: 'graph-tooltip',
|
||||
content: (label: string, xval: number, yval: number, flotItem: any) => {
|
||||
const series = flotItem.series; // TODO: type this.
|
||||
const date = '<span class="date">' + new Date(xval).toUTCString() + '</span>';
|
||||
const swatch = '<span class="detail-swatch" style="background-color: ' + series.color + '"></span>';
|
||||
const content = swatch + (series.labels.__name__ || 'value') + ': <strong>' + yval + '</strong>';
|
||||
return date + '<br>' + content + '<br>' + this.renderLabels(series.labels);
|
||||
content: (_, xval, yval, { series }): string => {
|
||||
const { labels, color } = series;
|
||||
return `
|
||||
<div class="date">${new Date(xval).toUTCString()}</div>
|
||||
<div>
|
||||
<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,
|
||||
lines: true,
|
||||
|
@ -141,15 +151,10 @@ class Graph extends PureComponent<GraphProps> {
|
|||
|
||||
// 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<GraphProps> {
|
|||
|
||||
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<GraphProps> {
|
|||
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<GraphProps> {
|
|||
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<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() {
|
||||
if (this.props.data === null) {
|
||||
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>;
|
||||
}
|
||||
|
||||
const { selectedSeriesIndex } = this.state;
|
||||
const series = this.getData();
|
||||
const canUseHover = series.length > 1 && selectedSeriesIndex === null;
|
||||
|
||||
return (
|
||||
<div className="graph">
|
||||
<ReactResizeDetector handleWidth onResize={() => this.plot()} />
|
||||
<ReactResizeDetector handleWidth onResize={this.plot} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<PanelProps & PathPrefixProps, PanelState> {
|
|||
onChangeResolution={this.handleChangeResolution}
|
||||
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>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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[];
|
||||
|
|
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