Skip to content

Commit 6c2164b

Browse files
Merge pull request #1237 from kyoto/query-browser-alert-graph
Monitoring: Add metric graphs to Alert and Rule details pages
2 parents 4c4828f + cb4fd3e commit 6c2164b

File tree

10 files changed

+1447
-476
lines changed

10 files changed

+1447
-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: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
tickformat: null, // Use Plotly's default datetime labels
38+
type: 'date',
39+
},
40+
});
41+
42+
this.onPlotlyRelayout = e => {
43+
if (e['xaxis.autorange']) {
44+
this.showLatest(this.state.span);
45+
} else {
46+
const start = e['xaxis.range[0]'];
47+
const end = e['xaxis.range[1]'];
48+
if (start && end) {
49+
// Zoom to a specific graph time range
50+
this.start = new Date(start).getTime();
51+
this.end = new Date(end).getTime();
52+
const span = this.end - this.start;
53+
this.timeSpan = span;
54+
this.setState({isSpanValid: true, span, spanText: formatPrometheusDuration(span), updating: true}, () => {
55+
clearInterval(this.interval);
56+
57+
// Refresh the graph data, but stop polling, since we are no longer displaying the latest data
58+
this.fetch(false);
59+
});
60+
}
61+
}
62+
};
63+
64+
this.relayout = () => {
65+
const now = new Date();
66+
const end = this.end || now;
67+
const start = this.start || new Date(end - this.state.span);
68+
// eslint-disable-next-line no-console
69+
relayout(this.node, {'xaxis.range': [start, end]}).catch(e => console.error(e));
70+
};
71+
72+
this.showLatest = span => {
73+
this.start = null;
74+
this.end = null;
75+
this.timeSpan = span;
76+
this.setState({isSpanValid: true, span, spanText: formatPrometheusDuration(span), updating: true}, () => {
77+
clearInterval(this.interval);
78+
this.fetch();
79+
this.relayout();
80+
});
81+
};
82+
83+
this.onSpanTextChange = e => {
84+
const spanText = e.target.value;
85+
const span = parsePrometheusDuration(spanText);
86+
const isSpanValid = (span > 0);
87+
if (isSpanValid) {
88+
this.showLatest(span);
89+
}
90+
this.setState({isSpanValid, spanText});
91+
};
92+
}
93+
94+
updateGraph(data) {
95+
const newData = _.get(data, '[0].data.result');
96+
if (!_.isEmpty(newData)) {
97+
this.data = newData;
98+
let traceIndex = 0;
99+
_.each(newData, ({metric, values}) => {
100+
// If props.metric is specified, ignore all other metrics
101+
const labels = _.omit(metric, '__name__');
102+
if (this.props.metric && _.some(labels, (v, k) => _.get(this.props.metric, k) !== v)) {
103+
return;
104+
}
105+
106+
// The data may have missing values, so we fill those gaps with nulls so that the graph correctly shows the
107+
// missing values as gaps in the line
108+
const start = values[0][0];
109+
const end = _.last(values)[0];
110+
const step = this.state.span / this.props.numSamples / 1000;
111+
_.range(start, end, step).map((t, i) => {
112+
if (_.get(values, [i, 0]) > t) {
113+
values.splice(i, 0, [t, null]);
114+
}
115+
});
116+
117+
const update = {
118+
line: {
119+
width: 1,
120+
},
121+
name: _.map(labels, (v, k) => `${k}=${v}`).join(','),
122+
x: [values.map(v => new Date(v[0] * 1000))],
123+
y: [values.map(v => v[1])],
124+
};
125+
126+
if (!this.traces.includes(traceIndex)) {
127+
// eslint-disable-next-line no-console
128+
addTraces(this.node, update, traceIndex).catch(e => console.error(e));
129+
this.traces.push(traceIndex);
130+
}
131+
// eslint-disable-next-line no-console
132+
restyle(this.node, update, [traceIndex]).catch(e => console.error(e));
133+
traceIndex += 1;
134+
});
135+
136+
this.relayout();
137+
}
138+
this.setState({updating: false});
139+
}
140+
141+
render() {
142+
const {query, timeSpan, urls} = this.props;
143+
const {spanText, isSpanValid, updating} = this.state;
144+
const baseUrl = urls[MonitoringRoutes.Prometheus];
145+
146+
return <div className="query-browser__wrapper">
147+
<div className="query-browser__header">
148+
<div className="query-browser__controls">
149+
<input
150+
className={classNames('form-control query-browser__span-text', {'query-browser__span-text--error': !isSpanValid})}
151+
onChange={this.onSpanTextChange}
152+
type="text"
153+
value={spanText}
154+
/>
155+
<Dropdown
156+
buttonClassName="btn-default form-control query-browser__span-dropdown"
157+
items={dropdownItems}
158+
noSelection={true}
159+
onChange={v => this.showLatest(parsePrometheusDuration(v))}
160+
/>
161+
<button
162+
className="btn btn-default query-browser__span-reset"
163+
onClick={() => this.showLatest(timeSpan)}
164+
type="button"
165+
>Reset Zoom</button>
166+
{updating && <LoadingInline />}
167+
</div>
168+
{baseUrl && query && <ExternalLink href={`${baseUrl}/graph?g0.expr=${encodeURIComponent(query)}&g0.tab=0`} text="View in Prometheus UI" />}
169+
</div>
170+
<div ref={this.setNode} style={{width: '100%'}} />
171+
</div>;
172+
}
173+
}
174+
export const QueryBrowser = connectToURLs(MonitoringRoutes.Prometheus)(QueryBrowser_);

0 commit comments

Comments
 (0)