Skip to content

Commit a550b15

Browse files
committed
Monitoring: Add metric graphs to Alert and Rule details pages
Adds a `QueryBrowser` component, which plots a PromQL query as a line graph along with controls for changing the graph to display the query for any time range. Adds the `QueryBrowser` to both the alert details page and the rule details page. The alert graph shows the single metric for that alert and the rule graph shows all metrics data for that rule. Upgrade Plotly to version 1.44.4. Move the "View in Prometheus UI" link from the top of the page to the graph area. Make some changes to the graph Base class. - Make the `title` property optional - Add the ability to invoke `fetch()` without enabling polling - Set a lower limit of 5 seconds on the graph refresh interval to prevent too frequent polling when viewing narrow time ranges. - Add optional `numSamples` property that defines how many data points will be plotted for each metric
1 parent 927c612 commit a550b15

File tree

10 files changed

+1444
-476
lines changed

10 files changed

+1444
-476
lines changed

frontend/__tests__/components/utils/datetime.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fromNow, isValid, formatDuration } from '../../../public/components/utils/datetime';
1+
import { fromNow, isValid, formatDuration, formatPrometheusDuration, parsePrometheusDuration } from '../../../public/components/utils/datetime';
22

33
describe('fromNow', () => {
44
it('prints past dates correctly', () => {
@@ -92,3 +92,84 @@ describe('formatDuration', () => {
9292
});
9393
});
9494

95+
// Converts time durations to milliseconds
96+
const ms = (s = 0, m = 0, h = 0, d = 0, w = 0) => ((((w * 7 + d) * 24 + h) * 60 + m) * 60 + s) * 1000;
97+
98+
describe('formatPrometheusDuration', () => {
99+
it('formats durations correctly', () => {
100+
expect(formatPrometheusDuration(ms(1))).toEqual('1s');
101+
expect(formatPrometheusDuration(ms(2, 1))).toEqual('1m 2s');
102+
expect(formatPrometheusDuration(ms(3, 2, 1))).toEqual('1h 2m 3s');
103+
expect(formatPrometheusDuration(ms(4, 3, 2, 1))).toEqual('1d 2h 3m 4s');
104+
expect(formatPrometheusDuration(ms(5, 4, 3, 2, 1))).toEqual('1w 2d 3h 4m 5s');
105+
});
106+
107+
it('handles invalid values', () => {
108+
[null, undefined, 0, -1, -9999].forEach(v => expect(formatPrometheusDuration(v)).toEqual(''));
109+
});
110+
});
111+
112+
describe('parsePrometheusDuration', () => {
113+
it('parses durations correctly', () => {
114+
expect(parsePrometheusDuration('1s')).toEqual(ms(1));
115+
expect(parsePrometheusDuration('100s')).toEqual(ms(100));
116+
expect(parsePrometheusDuration('1m')).toEqual(ms(0, 1));
117+
expect(parsePrometheusDuration('90m')).toEqual(ms(0, 90));
118+
expect(parsePrometheusDuration('1h')).toEqual(ms(0, 0, 1));
119+
expect(parsePrometheusDuration('2h 0m 0s')).toEqual(ms(0, 0, 2));
120+
expect(parsePrometheusDuration('13h 10m 23s')).toEqual(ms(23, 10, 13));
121+
expect(parsePrometheusDuration('25h 61m 61s')).toEqual(ms(61, 61, 25));
122+
expect(parsePrometheusDuration('123h')).toEqual(ms(0, 0, 123));
123+
expect(parsePrometheusDuration('1d')).toEqual(ms(0, 0, 0, 1));
124+
expect(parsePrometheusDuration('2d 6h')).toEqual(ms(0, 0, 6, 2));
125+
expect(parsePrometheusDuration('8d 12h')).toEqual(ms(0, 0, 12, 8));
126+
expect(parsePrometheusDuration('10d 12h 30m 1s')).toEqual(ms(1, 30, 12, 10));
127+
expect(parsePrometheusDuration('1w')).toEqual(ms(0, 0, 0, 0, 1));
128+
expect(parsePrometheusDuration('5w 10d 12h 30m 1s')).toEqual(ms(1, 30, 12, 10, 5));
129+
expect(parsePrometheusDuration('999w 999h 999s')).toEqual(ms(999, 0, 999, 0, 999));
130+
});
131+
132+
it('handles 0 values', () => {
133+
expect(parsePrometheusDuration('0s')).toEqual(0);
134+
expect(parsePrometheusDuration('0w 0d 0h 0m 0s')).toEqual(0);
135+
expect(parsePrometheusDuration('00h 000000m 0s')).toEqual(0);
136+
});
137+
138+
it('handles invalid duration formats', () => {
139+
[
140+
'',
141+
null,
142+
undefined,
143+
'0',
144+
'12',
145+
'z',
146+
'h',
147+
'abc',
148+
'全角',
149+
'0.5h',
150+
'1hh',
151+
'1h1m',
152+
'1h h',
153+
'1h 0',
154+
'1h 0z',
155+
'-1h',
156+
].forEach(v => expect(parsePrometheusDuration(v)).toEqual(0));
157+
});
158+
159+
it('mirrors formatPrometheusDuration()', () => {
160+
[
161+
'1s',
162+
'1m',
163+
'1h',
164+
'1m 40s',
165+
'13h 10m 23s',
166+
'2h 10s',
167+
'1d',
168+
'2d 6h',
169+
'1w',
170+
'5w 6d 12h 30m 1s',
171+
'999w',
172+
'',
173+
].forEach(v => expect(formatPrometheusDuration(parsePrometheusDuration(v))).toEqual(v));
174+
});
175+
});

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@
7171
"patternfly": "^3.59.0",
7272
"patternfly-react": "^2.29.1",
7373
"patternfly-react-extensions": "2.14.1",
74-
"plotly.js": "1.28.x",
74+
"plotly.js": "1.44.4",
7575
"prop-types": "15.6.x",
7676
"react": "16.6.3",
7777
"react-copy-to-clipboard": "5.x",

frontend/public/components/graphs/_graphs.scss

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,43 @@
1414
white-space: nowrap;
1515
line-height: 1.4; // so descenders don't clip
1616
}
17+
18+
.query-browser__wrapper {
19+
border: 1px solid $color-grey-background-border;
20+
margin: 0 0 20px 0;
21+
overflow: visible;
22+
width: 100%;
23+
}
24+
25+
.query-browser__header {
26+
display: inline-flex;
27+
justify-content: space-between;
28+
padding: 15px 10px 10px 10px;
29+
width: 100%;
30+
}
31+
32+
.query-browser__controls {
33+
display: inline-flex;
34+
}
35+
36+
.query-browser__span-text {
37+
border-bottom-right-radius: 0;
38+
border-right: none;
39+
border-top-right-radius: 0;
40+
width: 100px;
41+
}
42+
43+
.query-browser__span-text--error {
44+
background-color: #fdd;
45+
}
46+
47+
.query-browser__span-dropdown {
48+
border-bottom-left-radius: 0;
49+
border-top-left-radius: 0;
50+
margin-right: 20px;
51+
width: 30px;
52+
}
53+
54+
.query-browser__span-reset {
55+
margin-right: 20px;
56+
}

frontend/public/components/graphs/base.jsx

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class BaseGraph extends SafetyFirst {
3232
}
3333
}
3434

35-
fetch() {
35+
fetch(enablePolling = true) {
3636
const timeSpan = this.end - this.start || this.timeSpan;
3737
const end = this.end || Date.now();
3838
const start = this.start || (end - timeSpan);
@@ -45,8 +45,8 @@ export class BaseGraph extends SafetyFirst {
4545
}
4646

4747
const basePath = this.props.basePath || (this.props.namespace ? prometheusTenancyBasePath : prometheusBasePath);
48-
const pollInterval = timeSpan / 120 || 15000;
49-
const stepSize = pollInterval / 1000;
48+
const pollInterval = timeSpan ? Math.max(timeSpan / 120, 5000) : 15000;
49+
const stepSize = (timeSpan && this.props.numSamples ? timeSpan / this.props.numSamples : pollInterval) / 1000;
5050
const promises = queries.map(q => {
5151
const nsParam = this.props.namespace ? `&namespace=${encodeURIComponent(this.props.namespace)}` : '';
5252
const url = this.timeSpan
@@ -64,11 +64,15 @@ export class BaseGraph extends SafetyFirst {
6464
}
6565
})
6666
.catch(error => this.updateGraph(null, error))
67-
.then(() => this.interval = setTimeout(() => {
68-
if (this.isMounted_) {
69-
this.fetch();
67+
.then(() => {
68+
if (enablePolling) {
69+
this.interval = setTimeout(() => {
70+
if (this.isMounted_) {
71+
this.fetch();
72+
}
73+
}, pollInterval);
7074
}
71-
}, pollInterval));
75+
});
7276
}
7377

7478
componentWillMount() {
@@ -132,7 +136,7 @@ export class BaseGraph extends SafetyFirst {
132136
const { title, className } = this.props;
133137
const url = this.props.query ? this.prometheusURL() : null;
134138
const graph = <div className={classNames('graph-wrapper', className)} style={this.style}>
135-
<h5 className="graph-title">{title}</h5>
139+
{title && <h5 className="graph-title">{title}</h5>}
136140
<div ref={this.setNode} style={{width: '100%'}} />
137141
</div>;
138142

@@ -154,7 +158,8 @@ BaseGraph.propTypes = {
154158
]),
155159
percent: PropTypes.number, // for gauge charts
156160
className: PropTypes.string,
157-
title: PropTypes.string.isRequired,
161+
numSamples: PropTypes.number,
162+
title: PropTypes.string,
158163
timeSpan: PropTypes.number,
159164
basePath: PropTypes.string,
160165
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { Bar } from './bar';
22
export { Gauge } from './gauge';
33
export { Line } from './line';
4+
export { QueryBrowser } from './query-browser';
45
export { Scalar } from './scalar';
56
export { Donut } from './donut';

frontend/public/components/graphs/index.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const prometheusBasePath = window.SERVER_FLAGS.prometheusBaseURL;
99
export const prometheusTenancyBasePath = window.SERVER_FLAGS.prometheusTenancyBaseURL;
1010
export const alertManagerBasePath = window.SERVER_FLAGS.alertManagerBaseURL;
1111

12+
export const QueryBrowser = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.QueryBrowser)} {...props} />;
1213
export const Bar = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Bar)} {...props} />;
1314
export const Gauge = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Gauge)} {...props} />;
1415
export const Line = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Line)} {...props} />;
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import * as React from 'react';
2+
import * as _ from 'lodash-es';
3+
import * as classNames from 'classnames';
4+
import { addTraces, relayout, restyle } from 'plotly.js/lib/core';
5+
6+
import { connectToURLs, MonitoringRoutes } from '../../monitoring';
7+
import { Dropdown, ExternalLink, LoadingInline } from '../utils';
8+
import { formatPrometheusDuration, parsePrometheusDuration } from '../utils/datetime';
9+
import { Line_ } from './line';
10+
11+
const spans = ['5m', '15m', '30m', '1h', '2h', '6h', '12h', '1d', '2d', '1w', '2w'];
12+
const dropdownItems = _.zipObject(spans, spans);
13+
14+
class QueryBrowser_ extends Line_ {
15+
constructor(props) {
16+
super(props);
17+
18+
_.assign(this.state, {
19+
isSpanValid: true,
20+
spanText: formatPrometheusDuration(props.timeSpan),
21+
span: props.timeSpan,
22+
updating: true,
23+
});
24+
25+
this.data = [{}];
26+
this.traces = [0];
27+
28+
_.merge(this.layout, {
29+
dragmode: 'zoom',
30+
height: 200,
31+
hoverlabel: {
32+
namelength: 80,
33+
},
34+
showlegend: false,
35+
xaxis: {
36+
fixedrange: false,
37+
type: 'date',
38+
},
39+
});
40+
41+
this.onPlotlyRelayout = e => {
42+
if (e['xaxis.autorange']) {
43+
this.showLatest(this.state.span);
44+
} else {
45+
const start = e['xaxis.range[0]'];
46+
const end = e['xaxis.range[1]'];
47+
if (start && end) {
48+
// Zoom to a specific graph time range
49+
this.start = new Date(start).getTime();
50+
this.end = new Date(end).getTime();
51+
this.timeSpan = this.end - this.start;
52+
this.setState({isSpanValid: true, spanText: formatPrometheusDuration(this.timeSpan), updating: true}, () => {
53+
clearInterval(this.interval);
54+
55+
// Refresh the graph data, but stop polling, since we are no longer displaying the latest data
56+
this.fetch(false);
57+
});
58+
}
59+
}
60+
};
61+
62+
this.relayout = () => {
63+
const now = new Date();
64+
const end = this.end || now;
65+
const start = this.start || new Date(end - this.state.span);
66+
// eslint-disable-next-line no-console
67+
relayout(this.node, {'xaxis.range': [start, end]}).catch(e => console.error(e));
68+
};
69+
70+
this.showLatest = span => {
71+
this.start = null;
72+
this.end = null;
73+
this.timeSpan = span;
74+
this.setState({isSpanValid: true, span, spanText: formatPrometheusDuration(span), updating: true}, () => {
75+
clearInterval(this.interval);
76+
this.fetch();
77+
});
78+
};
79+
80+
this.onSpanTextChange = e => {
81+
const spanText = e.target.value;
82+
const span = parsePrometheusDuration(spanText);
83+
const isSpanValid = (span > 0);
84+
if (isSpanValid) {
85+
this.showLatest(span);
86+
}
87+
this.setState({isSpanValid, spanText});
88+
};
89+
}
90+
91+
updateGraph(data) {
92+
const newData = _.get(data, '[0].data.result');
93+
if (!_.isEmpty(newData)) {
94+
this.data = newData;
95+
let traceIndex = 0;
96+
_.each(newData, ({metric, values}) => {
97+
// If props.metric is specified, ignore all other metrics
98+
const labels = _.omit(metric, '__name__');
99+
if (this.props.metric && _.some(labels, (v, k) => _.get(this.props.metric, k) !== v)) {
100+
return;
101+
}
102+
103+
// The data may have missing values, so we fill those gaps with nulls so that the graph correctly shows the
104+
// missing values as gaps in the line
105+
const start = values[0][0];
106+
const end = _.last(values)[0];
107+
const step = this.state.span / this.props.numSamples / 1000;
108+
_.range(start, end, step).map((t, i) => {
109+
if (_.get(values, [i, 0]) > t) {
110+
values.splice(i, 0, [t, null]);
111+
}
112+
});
113+
114+
const update = {
115+
line: {
116+
width: 1,
117+
},
118+
name: _.map(labels, (v, k) => `${k}=${v}`).join(','),
119+
x: [values.map(v => new Date(v[0] * 1000))],
120+
y: [values.map(v => v[1])],
121+
};
122+
123+
if (!this.traces.includes(traceIndex)) {
124+
// eslint-disable-next-line no-console
125+
addTraces(this.node, update, traceIndex).catch(e => console.error(e));
126+
this.traces.push(traceIndex);
127+
}
128+
// eslint-disable-next-line no-console
129+
restyle(this.node, update, [traceIndex]).catch(e => console.error(e));
130+
traceIndex += 1;
131+
});
132+
133+
this.relayout();
134+
}
135+
this.setState({updating: false});
136+
}
137+
138+
render() {
139+
const {query, timeSpan, urls} = this.props;
140+
const {spanText, isSpanValid, updating} = this.state;
141+
const baseUrl = urls[MonitoringRoutes.Prometheus];
142+
143+
return <div className="query-browser__wrapper">
144+
<div className="query-browser__header">
145+
<div className="query-browser__controls">
146+
<input
147+
className={classNames('form-control query-browser__span-text', {'query-browser__span-text--error': !isSpanValid})}
148+
onChange={this.onSpanTextChange}
149+
type="text"
150+
value={spanText}
151+
/>
152+
<Dropdown
153+
buttonClassName="btn-default form-control query-browser__span-dropdown"
154+
items={dropdownItems}
155+
noSelection={true}
156+
onChange={v => this.showLatest(parsePrometheusDuration(v))}
157+
/>
158+
<button
159+
className="btn btn-default query-browser__span-reset"
160+
onClick={() => this.showLatest(timeSpan)}
161+
type="button"
162+
>Reset Zoom</button>
163+
{updating && <LoadingInline />}
164+
</div>
165+
{baseUrl && query && <ExternalLink href={`${baseUrl}/graph?g0.expr=${encodeURIComponent(query)}&g0.tab=0`} text="View in Prometheus UI" />}
166+
</div>
167+
<div ref={this.setNode} style={{width: '100%'}} />
168+
</div>;
169+
}
170+
}
171+
export const QueryBrowser = connectToURLs(MonitoringRoutes.Prometheus)(QueryBrowser_);

0 commit comments

Comments
 (0)