prometheus/web/ui/react-app/src/pages/graph/Graph.tsx
Levi Harrison f0fe189d20
React UI: Add Exemplar Support to Graph (#8832)
* Added exemplar support

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Modified tests

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fix eslint suggestions

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Address review comments

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fixed undefined data property error

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Added series label section to tooltip

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fixed spacing

Signed-off-by: GitHub <noreply@github.com>
Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fixed tests

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Added exemplar info

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Changed exemplar symbol

Signed-off-by: Levi Harrison <git@leviharrison.dev>

Co-authored-by: Julius Volz <julius.volz@gmail.com>
Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Hide selected exemplar info when 'Show Exemplars' is unchecked

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Include series labels in exemplar info

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* De-densify exemplars

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Moved showExemplars to per-panel control

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Eslint fixes

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Address review comments

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fixed tests

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fix state bug

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Removed unused object

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Fix eslint

Signed-off-by: Levi Harrison <git@leviharrison.dev>

* Encoded 'show_exemplars' in url

Signed-off-by: Levi Harrison <git@leviharrison.dev>

Co-authored-by: Julius Volz <julius.volz@gmail.com>
2021-06-12 18:02:40 +02:00

238 lines
7.3 KiB
TypeScript

import $ from 'jquery';
import React, { PureComponent } from 'react';
import ReactResizeDetector from 'react-resize-detector';
import { Legend } from './Legend';
import { Metric, ExemplarData, QueryParams } from '../../types/types';
import { isPresent } from '../../utils';
import { normalizeData, getOptions, toHoverColor } from './GraphHelpers';
require('../../vendor/flot/jquery.flot');
require('../../vendor/flot/jquery.flot.stack');
require('../../vendor/flot/jquery.flot.time');
require('../../vendor/flot/jquery.flot.crosshair');
require('jquery.flot.tooltip');
export interface GraphProps {
data: {
resultType: string;
result: Array<{ metric: Metric; values: [number, string][] }>;
};
exemplars: ExemplarData;
stacked: boolean;
useLocalTime: boolean;
showExemplars: boolean;
queryParams: QueryParams | null;
id: string;
}
export interface GraphSeries {
labels: { [key: string]: string };
color: string;
data: (number | null)[][]; // [x,y][]
index: number;
}
export interface GraphExemplar {
seriesLabels: { [key: string]: string };
labels: { [key: string]: string };
data: number[][];
points: any; // This is used to specify the symbol.
color: string;
}
export interface GraphData {
series: GraphSeries[];
exemplars: GraphExemplar[];
}
interface GraphState {
chartData: GraphData;
selectedExemplarLabels: { exemplar: { [key: string]: string }; series: { [key: string]: string } };
}
class Graph extends PureComponent<GraphProps, GraphState> {
private chartRef = React.createRef<HTMLDivElement>();
private $chart?: jquery.flot.plot;
private rafID = 0;
private selectedSeriesIndexes: number[] = [];
state = {
chartData: normalizeData(this.props),
selectedExemplarLabels: { exemplar: {}, series: {} },
};
componentDidUpdate(prevProps: GraphProps) {
const { data, stacked, useLocalTime, showExemplars } = this.props;
if (prevProps.data !== data) {
this.selectedSeriesIndexes = [];
this.setState({ chartData: normalizeData(this.props) }, this.plot);
} else if (prevProps.stacked !== stacked) {
this.setState({ chartData: normalizeData(this.props) }, () => {
if (this.selectedSeriesIndexes.length === 0) {
this.plot();
} else {
this.plot([
...this.state.chartData.series.filter((_, i) => this.selectedSeriesIndexes.includes(i)),
...this.state.chartData.exemplars,
]);
}
});
}
if (prevProps.useLocalTime !== useLocalTime) {
this.plot();
}
if (prevProps.showExemplars !== showExemplars && !showExemplars) {
this.setState(
{
chartData: { series: this.state.chartData.series, exemplars: [] },
selectedExemplarLabels: { exemplar: {}, series: {} },
},
() => {
this.plot();
}
);
}
}
componentDidMount() {
this.plot();
$(`.graph-${this.props.id}`).bind('plotclick', (event, pos, item) => {
// If an item has the series label property that means it's an exemplar.
if (item && 'seriesLabels' in item.series) {
this.setState({
selectedExemplarLabels: { exemplar: item.series.labels, series: item.series.seriesLabels },
chartData: this.state.chartData,
});
} else {
this.setState({
chartData: this.state.chartData,
selectedExemplarLabels: { exemplar: {}, series: {} },
});
}
});
}
componentWillUnmount() {
this.destroyPlot();
}
plot = (data: (GraphSeries | GraphExemplar)[] = [...this.state.chartData.series, ...this.state.chartData.exemplars]) => {
if (!this.chartRef.current) {
return;
}
this.destroyPlot();
this.$chart = $.plot($(this.chartRef.current), data, getOptions(this.props.stacked, this.props.useLocalTime));
};
destroyPlot = () => {
if (isPresent(this.$chart)) {
this.$chart.destroy();
}
};
plotSetAndDraw(
data: (GraphSeries | GraphExemplar)[] = [...this.state.chartData.series, ...this.state.chartData.exemplars]
) {
if (isPresent(this.$chart)) {
this.$chart.setData(data);
this.$chart.draw();
}
}
handleSeriesSelect = (selected: number[], selectedIndex: number) => {
const { chartData } = this.state;
this.plot(
this.selectedSeriesIndexes.length === 1 && this.selectedSeriesIndexes.includes(selectedIndex)
? [...chartData.series.map(toHoverColor(selectedIndex, this.props.stacked)), ...chartData.exemplars]
: [
...chartData.series.filter((_, i) => selected.includes(i)),
...chartData.exemplars.filter(exemplar => {
series: for (const i in selected) {
for (const name in chartData.series[selected[i]].labels) {
if (exemplar.seriesLabels[name] !== chartData.series[selected[i]].labels[name]) {
continue series;
}
}
return true;
}
return false;
}),
] // draw only selected
);
this.selectedSeriesIndexes = selected;
};
handleSeriesHover = (index: number) => () => {
if (this.rafID) {
cancelAnimationFrame(this.rafID);
}
this.rafID = requestAnimationFrame(() => {
this.plotSetAndDraw([
...this.state.chartData.series.map(toHoverColor(index, this.props.stacked)),
...this.state.chartData.exemplars,
]);
});
};
handleLegendMouseOut = () => {
cancelAnimationFrame(this.rafID);
this.plotSetAndDraw();
};
handleResize = () => {
if (isPresent(this.$chart)) {
this.plot(this.$chart.getData() as (GraphSeries | GraphExemplar)[]);
}
};
render() {
const { chartData, selectedExemplarLabels } = this.state;
const selectedLabels = selectedExemplarLabels as {
exemplar: { [key: string]: string };
series: { [key: string]: string };
};
return (
<div className={`graph-${this.props.id}`}>
<ReactResizeDetector handleWidth onResize={this.handleResize} skipOnMount />
<div className="graph-chart" ref={this.chartRef} />
{Object.keys(selectedLabels.exemplar).length > 0 ? (
<div className="float-right">
<span style={{ fontSize: '17px' }}>Selected exemplar:</span>
<div className="labels mt-1">
{Object.keys(selectedLabels.exemplar).map((k, i) => (
<div key={i} style={{ fontSize: '15px' }}>
<strong>{k}</strong>: {selectedLabels.exemplar[k]}
</div>
))}
</div>
<span style={{ fontSize: '16px' }}>Series labels:</span>
<div className="labels mt-1">
{Object.keys(selectedLabels.series).map((k, i) => (
<div key={i} style={{ fontSize: '15px' }}>
<strong>{k}</strong>: {selectedLabels.series[k]}
</div>
))}
</div>
</div>
) : null}
<Legend
shouldReset={this.selectedSeriesIndexes.length === 0}
chartData={chartData.series}
onHover={this.handleSeriesHover}
onLegendMouseOut={this.handleLegendMouseOut}
onSeriesToggle={this.handleSeriesSelect}
/>
{/* This is to make sure the graph box expands when the selected exemplar info pops up. */}
<br style={{ clear: 'both' }} />
</div>
);
}
}
export default Graph;