Commit d774d714 authored by Simon Knox's avatar Simon Knox Committed by Mike Greiling

Resolve "Show alert thresholds on dashboard"

parent 592ff4e9
......@@ -252,12 +252,26 @@ export default {
:small-graph="forceSmallGraph"
>
<!-- EE content -->
<template
slot="additionalSvgContent"
scope="{ graphDrawData }"
>
<threshold-lines
v-for="(alert, alertName) in alertData[graphData.id]"
:key="alertName"
:operator="alert.operator"
:threshold="alert.threshold"
:graph-draw-data="graphDrawData"
/>
</template>
<alert-widget
v-if="alertsEndpoint && graphData.id"
:alerts-endpoint="alertsEndpoint"
:label="getGraphLabel(graphData)"
:current-alerts="getQueryAlerts(graphData)"
:custom-metric-id="graphData.id"
:alert-data="alertData[graphData.id]"
@setAlerts="setAlerts"
/>
</graph>
</graph-group>
......
......@@ -82,6 +82,7 @@ export default {
showFlag: false,
showFlagContent: false,
timeSeries: [],
graphDrawData: {},
realPixelRatio: 1,
seriesUnderMouse: [],
};
......@@ -180,12 +181,12 @@ export default {
});
},
renderAxesPaths() {
this.timeSeries = createTimeSeries(
({ timeSeries: this.timeSeries, graphDrawData: this.graphDrawData } = createTimeSeries(
this.graphData.queries,
this.graphWidth,
this.graphHeight,
this.graphHeightOffset,
);
));
if (_.findWhere(this.timeSeries, { renderCanary: true })) {
this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
......@@ -288,6 +289,10 @@ export default {
:viewBox="innerViewBox"
class="graph-data"
>
<slot
name="additionalSvgContent"
:graphDrawData="graphDrawData"
/>
<graph-path
v-for="(path, index) in timeSeries"
:key="index"
......
......@@ -30,7 +30,7 @@ const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
function queryTimeSeries(query, graphDrawData, lineStyle) {
let usedColors = [];
let renderCanary = false;
const timeSeriesParsed = [];
......@@ -64,7 +64,7 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
// but we need a regularly-spaced set of time/value pairs
// this gives us a complete range of one minute intervals
// offset the same amount as the original data
const [minX, maxX] = xDom;
const [minX, maxX] = graphDrawData.xDom;
const offset = d3.timeMinute(minX) - Number(minX);
const datesWithoutGaps = d3.timeSecond.every(60)
.range(d3.timeMinute.offset(minX, -1), maxX)
......@@ -84,31 +84,6 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
renderCanary = true;
}
const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
timeSeriesScaleY.domain(yDom);
const defined = d => !Number.isNaN(d.value) && d.value != null;
const lineFunction = d3
.line()
.defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
const areaFunction = d3
.area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
.y0(graphHeight - graphHeightOffset)
.y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
const seriesCustomizationData =
query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
......@@ -144,10 +119,10 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
}));
timeSeriesParsed.push({
linePath: lineFunction(values),
areaPath: areaFunction(values),
timeSeriesScaleX,
timeSeriesScaleY,
linePath: graphDrawData.lineFunction(values),
areaPath: graphDrawData.areaBelowLine(values),
timeSeriesScaleX: graphDrawData.timeSeriesScaleX,
timeSeriesScaleY: graphDrawData.timeSeriesScaleY,
values: timeSeries.values,
max: maximumValue,
average: accum / timeSeries.values.length,
......@@ -164,7 +139,7 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return timeSeriesParsed;
}
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
function xyDomain(queries) {
const allValues = queries.reduce(
(allQueryResults, query) =>
allQueryResults.concat(
......@@ -176,10 +151,70 @@ export default function createTimeSeries(queries, graphWidth, graphHeight, graph
const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))];
return queries.reduce((series, query, index) => {
return {
xDom,
yDom,
};
}
export function generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset) {
const { xDom, yDom } = xyDomain(queries);
const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60);
timeSeriesScaleY.domain(yDom);
const defined = d => !Number.isNaN(d.value) && d.value != null;
const lineFunction = d3
.line()
.defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value));
const areaBelowLine = d3
.area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
.y0(graphHeight - graphHeightOffset)
.y1(d => timeSeriesScaleY(d.value));
const areaAboveLine = d3
.area()
.defined(defined)
.curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time))
.y0(0)
.y1(d => timeSeriesScaleY(d.value));
return {
lineFunction,
areaBelowLine,
areaAboveLine,
xDom,
yDom,
timeSeriesScaleX,
timeSeriesScaleY,
};
}
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
const graphDrawData = generateGraphDrawData(queries, graphWidth, graphHeight, graphHeightOffset);
const timeSeries = queries.reduce((series, query, index) => {
const lineStyle = defaultStyleOrder[index % defaultStyleOrder.length];
return series.concat(
queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle),
queryTimeSeries(query, graphDrawData, lineStyle),
);
}, []);
return {
timeSeries,
graphDrawData,
};
}
......@@ -28,6 +28,11 @@ export default {
require: false,
default: null,
},
alertData: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
......@@ -36,7 +41,6 @@ export default {
isLoading: false,
isOpen: false,
alerts: this.currentAlerts,
alertData: {},
};
},
computed: {
......@@ -59,7 +63,7 @@ export default {
: s__('PrometheusAlerts|Add alert');
},
hasAlerts() {
return this.alerts.length > 0;
return Object.keys(this.alertData).length > 0;
},
firstAlert() {
return this.hasAlerts ? this.alerts[0] : undefined;
......@@ -95,7 +99,12 @@ export default {
this.alerts.map(alertPath =>
this.service
.readAlert(alertPath)
.then(alertData => this.$set(this.alertData, alertPath, alertData)),
.then(alertData => {
this.$emit('setAlerts', this.customMetricId, {
...this.alertData,
[alertPath]: alertData,
});
}),
),
)
.then(() => {
......@@ -125,7 +134,10 @@ export default {
.then(response => {
const alertPath = response.alert_path;
this.alerts.unshift(alertPath);
this.$set(this.alertData, alertPath, newAlert);
this.$emit('setAlerts', this.customMetricId, {
...this.alertData,
[alertPath]: newAlert,
});
this.isLoading = false;
this.handleDropdownClose();
})
......@@ -140,7 +152,10 @@ export default {
this.service
.updateAlert(alert, updatedAlert)
.then(() => {
this.$set(this.alertData, alert, updatedAlert);
this.$emit('setAlerts', this.customMetricId, {
...this.alertData,
[alert]: updatedAlert,
});
this.isLoading = false;
this.handleDropdownClose();
})
......@@ -154,7 +169,8 @@ export default {
this.service
.deleteAlert(alert)
.then(() => {
this.$delete(this.alertData, alert);
const { [alert]: _, ...otherItems } = this.alertData;
this.$emit('setAlerts', this.customMetricId, otherItems);
this.alerts = this.alerts.filter(alertPath => alert !== alertPath);
this.isLoading = false;
this.handleDropdownClose();
......
import AlertWidget from './alert_widget.vue';
import ThresholdLines from './threshold_lines.vue';
export default {
components: {
AlertWidget,
ThresholdLines,
},
props: {
alertsEndpoint: {
......@@ -11,6 +13,11 @@ export default {
default: null,
},
},
data() {
return {
alertData: {},
};
},
methods: {
getGraphLabel(graphData) {
if (!graphData.queries || !graphData.queries[0]) return undefined;
......@@ -20,5 +27,8 @@ export default {
if (!graphData.queries) return [];
return graphData.queries.map(query => query.alert_path).filter(Boolean);
},
setAlerts(metricId, alertData) {
this.$set(this.alertData, metricId, alertData);
},
},
};
<script>
const red50 = '#fef6f5';
const red400 = '#e05842';
export default {
props: {
operator: {
type: String,
required: true,
validator: val => ['=', '<', '>'].includes(val),
},
threshold: {
type: Number,
required: true,
},
graphDrawData: {
type: Object,
required: true,
},
},
computed: {
thresholdData() {
if (!this.graphDrawData.xDom) {
return [];
}
const [xMin, xMax] = this.graphDrawData.xDom;
const [yMin, yMax] = this.graphDrawData.yDom;
const outOfRange = (this.operator === '>' && this.threshold > yMax) ||
(this.operator === '<' && this.threshold < yMin);
if (outOfRange) {
return [];
}
return [
{ time: xMin, value: this.threshold },
{ time: xMax, value: this.threshold },
];
},
linePath() {
if (!this.graphDrawData.lineFunction) {
return '';
}
return this.graphDrawData.lineFunction(this.thresholdData);
},
areaPath() {
if (this.operator === '>') {
if (!this.graphDrawData.areaAboveLine) {
return '';
}
return this.graphDrawData.areaAboveLine(this.thresholdData);
} else if (this.operator === '<') {
if (!this.graphDrawData.areaBelowLine) {
return '';
}
return this.graphDrawData.areaBelowLine(this.thresholdData);
}
return '';
},
},
created() {
this.red50 = red50;
this.red400 = red400;
},
};
</script>
<template>
<g
v-if="thresholdData.length"
transform="translate(-5, 20)"
class="js-threshold-lines"
>
<path
v-if="areaPath"
:d="areaPath"
:fill="red50"
/>
<path
:d="linePath"
fill="none"
:stroke="red400"
stroke-width="1"
stroke-dasharray="solid"
/>
</g>
</template>
---
title: Show Alert Thresholds on monitoring dashboards
merge_request: 7538
author:
type: added
import Vue from 'vue';
import MockAdapter from 'axios-mock-adapter';
import Dashboard from '~/monitoring/components/dashboard.vue';
import axios from '~/lib/utils/axios_utils';
import { metricsGroupsAPIResponse, mockApiEndpoint } from 'spec/monitoring/mock_data';
import propsData from 'spec/monitoring/dashboard_spec';
describe('Dashboard', () => {
let Component;
let mock;
let vm;
beforeEach(() => {
setFixtures(`
<div class="prometheus-graphs"></div>
<div class="nav-sidebar"></div>
`);
mock = new MockAdapter(axios);
Component = Vue.extend(Dashboard);
});
afterEach(() => {
mock.restore();
});
describe('metrics without alerts', () => {
it('does not show threshold lines', (done) => {
vm = new Component({
el: document.querySelector('.prometheus-graphs'),
propsData: {
...propsData,
hasMetrics: true,
},
});
setTimeout(() => {
expect(vm.$el).not.toContainElement('.js-threshold-lines');
done();
});
});
});
describe('metrics with alert', () => {
const metricId = 5;
const alertParams = {
operator: '<',
threshold: 4,
prometheus_metric_id: metricId,
};
beforeEach(() => {
mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse);
vm = new Component({
el: document.querySelector('.prometheus-graphs'),
propsData: {
...propsData,
hasMetrics: true,
},
});
});
it('shows single threshold line', (done) => {
vm.setAlerts(metricId, {
alertName: alertParams,
});
setTimeout(() => {
expect(vm.$el.querySelectorAll('.js-threshold-lines').length).toEqual(1);
done();
});
});
it('shows multiple threshold lines', (done) => {
vm.setAlerts(metricId, {
someAlert: alertParams,
otherAlert: alertParams,
});
setTimeout(() => {
expect(vm.$el.querySelectorAll('.js-threshold-lines').length).toEqual(2);
done();
});
});
});
});
import Vue from 'vue';
import ThresholdLines from 'ee/monitoring/components/threshold_lines.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { generateGraphDrawData } from '~/monitoring/utils/multiple_time_series';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from 'spec/monitoring/mock_data';
const width = 500;
const height = 200;
const heightOffset = 50;
describe('ThresholdLines', () => {
let Component;
let vm;
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const { queries } = convertedMetrics[0];
const graphDrawData = generateGraphDrawData(queries, width, height, heightOffset);
beforeEach(() => {
Component = Vue.extend(ThresholdLines);
spyOn(graphDrawData, 'areaAboveLine').and.callThrough();
spyOn(graphDrawData, 'areaBelowLine').and.callThrough();
spyOn(graphDrawData, 'lineFunction').and.callThrough();
});
describe('< alerts', () => {
beforeEach(() => {
const props = {
operator: '<',
threshold: 0.6,
graphDrawData,
};
vm = mountComponent(Component, props);
});
it('fills area', () => {
expect(vm.$el.querySelectorAll('path').length).toEqual(2);
expect(graphDrawData.areaBelowLine).toHaveBeenCalled();
expect(graphDrawData.lineFunction).toHaveBeenCalled();
});
});
describe('> alerts', () => {
it('fills area', () => {
const props = {
operator: '>',
threshold: 0.6,
graphDrawData,
};
vm = mountComponent(Component, props);
expect(vm.$el.querySelectorAll('path').length).toEqual(2);
expect(graphDrawData.areaAboveLine).toHaveBeenCalled();
expect(graphDrawData.lineFunction).toHaveBeenCalled();
});
it('hides area if threshold out of range', () => {
const props = {
operator: '>',
threshold: 1000,
graphDrawData,
};
vm = mountComponent(Component, props);
expect(vm.$el.innerHTML).not.toBeDefined();
expect(graphDrawData.areaAboveLine).not.toHaveBeenCalled();
expect(graphDrawData.lineFunction).not.toHaveBeenCalled();
});
});
describe('= alerts', () => {
it('draws line only', () => {
const props = {
operator: '=',
threshold: 0.6,
graphDrawData,
};
vm = mountComponent(Component, props);
expect(vm.$el.querySelectorAll('path').length).toEqual(1);
expect(graphDrawData.lineFunction).toHaveBeenCalled();
});
});
});
......@@ -13,6 +13,11 @@ describe('AlertWidget', () => {
currentAlerts: ['my/alert.json'],
};
const mockSetAlerts = (_, data) => {
/* eslint-disable-next-line no-underscore-dangle */
Vue.set(vm._props, 'alertData', data);
};
beforeAll(() => {
AlertWidgetComponent = Vue.extend(AlertWidget);
});
......@@ -69,7 +74,11 @@ describe('AlertWidget', () => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
Promise.resolve({ operator: '>', threshold: 42 }),
);
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
const propsWithAlertData = {
...props,
alertData: { 'my/alert.json': { operator: '>', threshold: 42 } },
};
vm = mountComponent(AlertWidgetComponent, propsWithAlertData, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
......@@ -140,6 +149,8 @@ describe('AlertWidget', () => {
);
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('create', alertParams);
expect(AlertsService.prototype.createAlert).toHaveBeenCalledWith(alertParams);
......@@ -161,6 +172,8 @@ describe('AlertWidget', () => {
spyOn(AlertsService.prototype, 'updateAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('update', {
...alertParams,
alert: alertPath,
......@@ -191,6 +204,8 @@ describe('AlertWidget', () => {
spyOn(AlertsService.prototype, 'deleteAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$on('setAlerts', mockSetAlerts);
vm.$refs.widgetForm.$emit('delete', { alert: alertPath });
expect(AlertsService.prototype.deleteAlert).toHaveBeenCalledWith(alertPath);
......
......@@ -4,30 +4,32 @@ import Dashboard from '~/monitoring/components/dashboard.vue';
import axios from '~/lib/utils/axios_utils';
import { metricsGroupsAPIResponse, mockApiEndpoint, environmentData } from './mock_data';
const propsData = {
hasMetrics: false,
documentationPath: '/path/to/docs',
settingsPath: '/path/to/settings',
clustersPath: '/path/to/clusters',
tagsPath: '/path/to/tags',
projectPath: '/path/to/project',
metricsEndpoint: mockApiEndpoint,
deploymentEndpoint: null,
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production',
};
export default propsData;
describe('Dashboard', () => {
let DashboardComponent;
const propsData = {
hasMetrics: false,
documentationPath: '/path/to/docs',
settingsPath: '/path/to/settings',
clustersPath: '/path/to/clusters',
tagsPath: '/path/to/tags',
projectPath: '/path/to/project',
metricsEndpoint: mockApiEndpoint,
deploymentEndpoint: null,
emptyGettingStartedSvgPath: '/path/to/getting-started.svg',
emptyLoadingSvgPath: '/path/to/loading.svg',
emptyNoDataSvgPath: '/path/to/no-data.svg',
emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg',
environmentsEndpoint: '/root/hello-prometheus/environments/35',
currentEnvironmentName: 'production',
};
beforeEach(() => {
setFixtures(`
<div class="prometheus-graphs"></div>
<div class="nav-sidebar"></div>
<div class="nav-sidebar"></div>
`);
DashboardComponent = Vue.extend(Dashboard);
});
......
......@@ -8,7 +8,7 @@ const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeri
const defaultValuesComponent = {};
const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
defaultValuesComponent.timeSeries = timeSeries;
......
......@@ -5,7 +5,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
describe('TrackInfo component', () => {
let vm;
......
......@@ -5,7 +5,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
describe('TrackLine component', () => {
let vm;
......
......@@ -13,7 +13,7 @@ const createComponent = (propsData) => {
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const firstTimeSeries = timeSeries[0];
describe('Monitoring Paths', () => {
......
......@@ -8,6 +8,7 @@ export const metricsGroupsAPIResponse = {
priority: 1,
metrics: [
{
id: 5,
title: 'Memory usage',
weight: 1,
queries: [
......
......@@ -2,7 +2,7 @@ import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import { convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from '../mock_data';
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const timeSeries = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const firstTimeSeries = timeSeries[0];
describe('Multiple time series', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment