Skip to content

Monitoring: Add metric graphs to Alert and Rule details pages #1237

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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_ {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

General comment that we've been trying to get away from inheritance in React components, but I'm fine with it here since it's how the other charts work.

https://reactjs.org/docs/composition-vs-inheritance.html

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