Skip to content

Commit

Permalink
Merge pull request openshift#1237 from kyoto/query-browser-alert-graph
Browse files Browse the repository at this point in the history
Monitoring: Add metric graphs to Alert and Rule details pages
  • Loading branch information
openshift-merge-robot authored Mar 9, 2019
2 parents 4c4828f + cb4fd3e commit 6c2164b
Show file tree
Hide file tree
Showing 10 changed files with 1,447 additions and 476 deletions.
83 changes: 82 additions & 1 deletion frontend/__tests__/components/utils/datetime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fromNow, isValid, formatDuration } from '../../../public/components/utils/datetime';
import { fromNow, isValid, formatDuration, formatPrometheusDuration, parsePrometheusDuration } from '../../../public/components/utils/datetime';

describe('fromNow', () => {
it('prints past dates correctly', () => {
Expand Down Expand Up @@ -92,3 +92,84 @@ describe('formatDuration', () => {
});
});

// Converts time durations to milliseconds
const ms = (s = 0, m = 0, h = 0, d = 0, w = 0) => ((((w * 7 + d) * 24 + h) * 60 + m) * 60 + s) * 1000;

describe('formatPrometheusDuration', () => {
it('formats durations correctly', () => {
expect(formatPrometheusDuration(ms(1))).toEqual('1s');
expect(formatPrometheusDuration(ms(2, 1))).toEqual('1m 2s');
expect(formatPrometheusDuration(ms(3, 2, 1))).toEqual('1h 2m 3s');
expect(formatPrometheusDuration(ms(4, 3, 2, 1))).toEqual('1d 2h 3m 4s');
expect(formatPrometheusDuration(ms(5, 4, 3, 2, 1))).toEqual('1w 2d 3h 4m 5s');
});

it('handles invalid values', () => {
[null, undefined, 0, -1, -9999].forEach(v => expect(formatPrometheusDuration(v)).toEqual(''));
});
});

describe('parsePrometheusDuration', () => {
it('parses durations correctly', () => {
expect(parsePrometheusDuration('1s')).toEqual(ms(1));
expect(parsePrometheusDuration('100s')).toEqual(ms(100));
expect(parsePrometheusDuration('1m')).toEqual(ms(0, 1));
expect(parsePrometheusDuration('90m')).toEqual(ms(0, 90));
expect(parsePrometheusDuration('1h')).toEqual(ms(0, 0, 1));
expect(parsePrometheusDuration('2h 0m 0s')).toEqual(ms(0, 0, 2));
expect(parsePrometheusDuration('13h 10m 23s')).toEqual(ms(23, 10, 13));
expect(parsePrometheusDuration('25h 61m 61s')).toEqual(ms(61, 61, 25));
expect(parsePrometheusDuration('123h')).toEqual(ms(0, 0, 123));
expect(parsePrometheusDuration('1d')).toEqual(ms(0, 0, 0, 1));
expect(parsePrometheusDuration('2d 6h')).toEqual(ms(0, 0, 6, 2));
expect(parsePrometheusDuration('8d 12h')).toEqual(ms(0, 0, 12, 8));
expect(parsePrometheusDuration('10d 12h 30m 1s')).toEqual(ms(1, 30, 12, 10));
expect(parsePrometheusDuration('1w')).toEqual(ms(0, 0, 0, 0, 1));
expect(parsePrometheusDuration('5w 10d 12h 30m 1s')).toEqual(ms(1, 30, 12, 10, 5));
expect(parsePrometheusDuration('999w 999h 999s')).toEqual(ms(999, 0, 999, 0, 999));
});

it('handles 0 values', () => {
expect(parsePrometheusDuration('0s')).toEqual(0);
expect(parsePrometheusDuration('0w 0d 0h 0m 0s')).toEqual(0);
expect(parsePrometheusDuration('00h 000000m 0s')).toEqual(0);
});

it('handles invalid duration formats', () => {
[
'',
null,
undefined,
'0',
'12',
'z',
'h',
'abc',
'全角',
'0.5h',
'1hh',
'1h1m',
'1h h',
'1h 0',
'1h 0z',
'-1h',
].forEach(v => expect(parsePrometheusDuration(v)).toEqual(0));
});

it('mirrors formatPrometheusDuration()', () => {
[
'1s',
'1m',
'1h',
'1m 40s',
'13h 10m 23s',
'2h 10s',
'1d',
'2d 6h',
'1w',
'5w 6d 12h 30m 1s',
'999w',
'',
].forEach(v => expect(formatPrometheusDuration(parsePrometheusDuration(v))).toEqual(v));
});
});
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@
"patternfly": "^3.59.0",
"patternfly-react": "^2.29.1",
"patternfly-react-extensions": "2.14.1",
"plotly.js": "1.28.x",
"plotly.js": "1.44.4",
"prop-types": "15.6.x",
"react": "16.6.3",
"react-copy-to-clipboard": "5.x",
Expand Down
40 changes: 40 additions & 0 deletions frontend/public/components/graphs/_graphs.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,43 @@
white-space: nowrap;
line-height: 1.4; // so descenders don't clip
}

.query-browser__wrapper {
border: 1px solid $color-grey-background-border;
margin: 0 0 20px 0;
overflow: visible;
width: 100%;
}

.query-browser__header {
display: inline-flex;
justify-content: space-between;
padding: 15px 10px 10px 10px;
width: 100%;
}

.query-browser__controls {
display: inline-flex;
}

.query-browser__span-text {
border-bottom-right-radius: 0;
border-right: none;
border-top-right-radius: 0;
width: 100px;
}

.query-browser__span-text--error {
background-color: #fdd;
}

.query-browser__span-dropdown {
border-bottom-left-radius: 0;
border-top-left-radius: 0;
margin-right: 20px;
width: 30px;
}

.query-browser__span-reset {
margin-right: 20px;
}
23 changes: 14 additions & 9 deletions frontend/public/components/graphs/base.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class BaseGraph extends SafetyFirst {
}
}

fetch() {
fetch(enablePolling = true) {
const timeSpan = this.end - this.start || this.timeSpan;
const end = this.end || Date.now();
const start = this.start || (end - timeSpan);
Expand All @@ -45,8 +45,8 @@ export class BaseGraph extends SafetyFirst {
}

const basePath = this.props.basePath || (this.props.namespace ? prometheusTenancyBasePath : prometheusBasePath);
const pollInterval = timeSpan / 120 || 15000;
const stepSize = pollInterval / 1000;
const pollInterval = timeSpan ? Math.max(timeSpan / 120, 5000) : 15000;
const stepSize = (timeSpan && this.props.numSamples ? timeSpan / this.props.numSamples : pollInterval) / 1000;
const promises = queries.map(q => {
const nsParam = this.props.namespace ? `&namespace=${encodeURIComponent(this.props.namespace)}` : '';
const url = this.timeSpan
Expand All @@ -64,11 +64,15 @@ export class BaseGraph extends SafetyFirst {
}
})
.catch(error => this.updateGraph(null, error))
.then(() => this.interval = setTimeout(() => {
if (this.isMounted_) {
this.fetch();
.then(() => {
if (enablePolling) {
this.interval = setTimeout(() => {
if (this.isMounted_) {
this.fetch();
}
}, pollInterval);
}
}, pollInterval));
});
}

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

Expand All @@ -154,7 +158,8 @@ BaseGraph.propTypes = {
]),
percent: PropTypes.number, // for gauge charts
className: PropTypes.string,
title: PropTypes.string.isRequired,
numSamples: PropTypes.number,
title: PropTypes.string,
timeSpan: PropTypes.number,
basePath: PropTypes.string,
};
Expand Down
1 change: 1 addition & 0 deletions frontend/public/components/graphs/graph-loader.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { Bar } from './bar';
export { Gauge } from './gauge';
export { Line } from './line';
export { QueryBrowser } from './query-browser';
export { Scalar } from './scalar';
export { Donut } from './donut';
1 change: 1 addition & 0 deletions frontend/public/components/graphs/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const prometheusBasePath = window.SERVER_FLAGS.prometheusBaseURL;
export const prometheusTenancyBasePath = window.SERVER_FLAGS.prometheusTenancyBaseURL;
export const alertManagerBasePath = window.SERVER_FLAGS.alertManagerBaseURL;

export const QueryBrowser = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.QueryBrowser)} {...props} />;
export const Bar = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Bar)} {...props} />;
export const Gauge = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Gauge)} {...props} />;
export const Line = props => <AsyncComponent loader={() => import('./graph-loader').then(c => c.Line)} {...props} />;
Expand Down
174 changes: 174 additions & 0 deletions frontend/public/components/graphs/query-browser.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import * as React from 'react';
import * as _ from 'lodash-es';
import * as classNames from 'classnames';
import { addTraces, relayout, restyle } from 'plotly.js/lib/core';

import { connectToURLs, MonitoringRoutes } from '../../monitoring';
import { Dropdown, ExternalLink, LoadingInline } from '../utils';
import { formatPrometheusDuration, parsePrometheusDuration } from '../utils/datetime';
import { Line_ } from './line';

const spans = ['5m', '15m', '30m', '1h', '2h', '6h', '12h', '1d', '2d', '1w', '2w'];
const dropdownItems = _.zipObject(spans, spans);

class QueryBrowser_ extends Line_ {
constructor(props) {
super(props);

_.assign(this.state, {
isSpanValid: true,
spanText: formatPrometheusDuration(props.timeSpan),
span: props.timeSpan,
updating: true,
});

this.data = [{}];
this.traces = [0];

_.merge(this.layout, {
dragmode: 'zoom',
height: 200,
hoverlabel: {
namelength: 80,
},
showlegend: false,
xaxis: {
fixedrange: false,
tickformat: null, // Use Plotly's default datetime labels
type: 'date',
},
});

this.onPlotlyRelayout = e => {
if (e['xaxis.autorange']) {
this.showLatest(this.state.span);
} else {
const start = e['xaxis.range[0]'];
const end = e['xaxis.range[1]'];
if (start && end) {
// Zoom to a specific graph time range
this.start = new Date(start).getTime();
this.end = new Date(end).getTime();
const span = this.end - this.start;
this.timeSpan = span;
this.setState({isSpanValid: true, span, spanText: formatPrometheusDuration(span), updating: true}, () => {
clearInterval(this.interval);

// Refresh the graph data, but stop polling, since we are no longer displaying the latest data
this.fetch(false);
});
}
}
};

this.relayout = () => {
const now = new Date();
const end = this.end || now;
const start = this.start || new Date(end - this.state.span);
// eslint-disable-next-line no-console
relayout(this.node, {'xaxis.range': [start, end]}).catch(e => console.error(e));
};

this.showLatest = span => {
this.start = null;
this.end = null;
this.timeSpan = span;
this.setState({isSpanValid: true, span, spanText: formatPrometheusDuration(span), updating: true}, () => {
clearInterval(this.interval);
this.fetch();
this.relayout();
});
};

this.onSpanTextChange = e => {
const spanText = e.target.value;
const span = parsePrometheusDuration(spanText);
const isSpanValid = (span > 0);
if (isSpanValid) {
this.showLatest(span);
}
this.setState({isSpanValid, spanText});
};
}

updateGraph(data) {
const newData = _.get(data, '[0].data.result');
if (!_.isEmpty(newData)) {
this.data = newData;
let traceIndex = 0;
_.each(newData, ({metric, values}) => {
// If props.metric is specified, ignore all other metrics
const labels = _.omit(metric, '__name__');
if (this.props.metric && _.some(labels, (v, k) => _.get(this.props.metric, k) !== v)) {
return;
}

// The data may have missing values, so we fill those gaps with nulls so that the graph correctly shows the
// missing values as gaps in the line
const start = values[0][0];
const end = _.last(values)[0];
const step = this.state.span / this.props.numSamples / 1000;
_.range(start, end, step).map((t, i) => {
if (_.get(values, [i, 0]) > t) {
values.splice(i, 0, [t, null]);
}
});

const update = {
line: {
width: 1,
},
name: _.map(labels, (v, k) => `${k}=${v}`).join(','),
x: [values.map(v => new Date(v[0] * 1000))],
y: [values.map(v => v[1])],
};

if (!this.traces.includes(traceIndex)) {
// eslint-disable-next-line no-console
addTraces(this.node, update, traceIndex).catch(e => console.error(e));
this.traces.push(traceIndex);
}
// eslint-disable-next-line no-console
restyle(this.node, update, [traceIndex]).catch(e => console.error(e));
traceIndex += 1;
});

this.relayout();
}
this.setState({updating: false});
}

render() {
const {query, timeSpan, urls} = this.props;
const {spanText, isSpanValid, updating} = this.state;
const baseUrl = urls[MonitoringRoutes.Prometheus];

return <div className="query-browser__wrapper">
<div className="query-browser__header">
<div className="query-browser__controls">
<input
className={classNames('form-control query-browser__span-text', {'query-browser__span-text--error': !isSpanValid})}
onChange={this.onSpanTextChange}
type="text"
value={spanText}
/>
<Dropdown
buttonClassName="btn-default form-control query-browser__span-dropdown"
items={dropdownItems}
noSelection={true}
onChange={v => this.showLatest(parsePrometheusDuration(v))}
/>
<button
className="btn btn-default query-browser__span-reset"
onClick={() => this.showLatest(timeSpan)}
type="button"
>Reset Zoom</button>
{updating && <LoadingInline />}
</div>
{baseUrl && query && <ExternalLink href={`${baseUrl}/graph?g0.expr=${encodeURIComponent(query)}&g0.tab=0`} text="View in Prometheus UI" />}
</div>
<div ref={this.setNode} style={{width: '100%'}} />
</div>;
}
}
export const QueryBrowser = connectToURLs(MonitoringRoutes.Prometheus)(QueryBrowser_);
Loading

0 comments on commit 6c2164b

Please sign in to comment.