Skip to content
Snippets Groups Projects
Commit f6600616 authored by kushalpandya's avatar kushalpandya
Browse files

Add Prometheus memory sparkline to MR widget

parent 7ea12d80
No related branches found
No related tags found
2 merge requests!12073Add RC2 changes to 9-3-stable,!11209Add Prometheus memory sparkline to MR widget
Pipeline #
Showing
with 579 additions and 74 deletions
Loading
Loading
@@ -108,8 +108,6 @@ export default {
</div>
<mr-widget-memory-usage
v-if="deployment.metrics_url"
:mr="mr"
:service="service"
:metricsUrl="deployment.metrics_url"
/>
</div>
Loading
Loading
Loading
Loading
@@ -5,8 +5,6 @@ import MRWidgetService from '../services/mr_widget_service';
export default {
name: 'MemoryUsage',
props: {
mr: { type: Object, required: true },
service: { type: Object, required: true },
metricsUrl: { type: String, required: true },
},
data() {
Loading
Loading
@@ -14,6 +12,7 @@ export default {
// memoryFrom: 0,
// memoryTo: 0,
memoryMetrics: [],
deploymentTime: 0,
hasMetrics: false,
loadFailed: false,
loadingMetrics: true,
Loading
Loading
@@ -23,8 +22,22 @@ export default {
components: {
'mr-memory-graph': MemoryGraph,
},
computed: {
shouldShowLoading() {
return this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
},
shouldShowMemoryGraph() {
return !this.loadingMetrics && this.hasMetrics && !this.loadFailed;
},
shouldShowLoadFailure() {
return !this.loadingMetrics && !this.hasMetrics && this.loadFailed;
},
shouldShowMetricsUnavailable() {
return !this.loadingMetrics && !this.hasMetrics && !this.loadFailed;
},
},
methods: {
computeGraphData(metrics) {
computeGraphData(metrics, deploymentTime) {
this.loadingMetrics = false;
const { memory_values } = metrics;
// if (memory_previous.length > 0) {
Loading
Loading
@@ -38,70 +51,73 @@ export default {
if (memory_values.length > 0) {
this.hasMetrics = true;
this.memoryMetrics = memory_values[0].values;
this.deploymentTime = deploymentTime;
}
},
},
mounted() {
this.$props.loadingMetrics = true;
gl.utils.backOff((next, stop) => {
MRWidgetService.fetchMetrics(this.$props.metricsUrl)
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < 3) {
next();
loadMetrics() {
gl.utils.backOff((next, stop) => {
MRWidgetService.fetchMetrics(this.metricsUrl)
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
this.backOffRequestCounter = this.backOffRequestCounter += 1;
/* eslint-disable no-unused-expressions */
this.backOffRequestCounter < 3 ? next() : stop(res);
} else {
stop(res);
}
} else {
stop(res);
})
.catch(stop);
})
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
return res;
}
})
.catch(stop);
})
.then((res) => {
if (res.status === statusCodes.NO_CONTENT) {
return res;
}
 
return res.json();
})
.then((res) => {
this.computeGraphData(res.metrics);
return res;
})
.catch(() => {
this.$props.loadFailed = true;
});
return res.json();
})
.then((res) => {
this.computeGraphData(res.metrics, res.deployment_time);
return res;
})
.catch(() => {
this.loadFailed = true;
this.loadingMetrics = false;
});
},
},
mounted() {
this.loadingMetrics = true;
this.loadMetrics();
},
template: `
<div class="mr-info-list mr-memory-usage">
<div class="mr-info-list clearfix mr-memory-usage js-mr-memory-usage">
<div class="legend"></div>
<p
v-if="loadingMetrics"
class="usage-info usage-info-loading">
v-if="shouldShowLoading"
class="usage-info js-usage-info usage-info-loading">
<i
class="fa fa-spinner fa-spin usage-info-load-spinner"
aria-hidden="true" />Loading deployment statistics.
</p>
<p
v-if="!hasMetrics && !loadingMetrics"
class="usage-info usage-info-loading">
Deployment statistics are not available currently.
</p>
<p
v-if="hasMetrics"
class="usage-info">
v-if="shouldShowMemoryGraph"
class="usage-info js-usage-info">
Deployment memory usage:
</p>
<p
v-if="loadFailed"
class="usage-info">
v-if="shouldShowLoadFailure"
class="usage-info js-usage-info usage-info-failed">
Failed to load deployment statistics.
</p>
<p
v-if="shouldShowMetricsUnavailable"
class="usage-info js-usage-info usage-info-unavailable">
Deployment statistics are not available currently.
</p>
<mr-memory-graph
v-if="hasMetrics"
v-if="shouldShowMemoryGraph"
:metrics="memoryMetrics"
:deploymentTime="deploymentTime"
height="25"
width="100" />
</div>
Loading
Loading
Loading
Loading
@@ -2,6 +2,7 @@ export default {
name: 'MemoryGraph',
props: {
metrics: { type: Array, required: true },
deploymentTime: { type: Number, required: true },
width: { type: String, required: true },
height: { type: String, required: true },
},
Loading
Loading
@@ -9,27 +10,105 @@ export default {
return {
pathD: '',
pathViewBox: '',
// dotX: '',
// dotY: '',
dotX: '',
dotY: '',
};
},
computed: {
getFormattedMedian() {
const deployedSince = gl.utils.getTimeago().format(this.deploymentTime * 1000);
return `Deployed ${deployedSince}`;
},
},
methods: {
/**
* Returns metric value index in metrics array
* with timestamp closest to matching median
*/
getMedianMetricIndex(median, metrics) {
let matchIndex = 0;
let timestampDiff = 0;
let smallestDiff = 0;
const metricTimestamps = metrics.map(v => v[0]);
// Find metric timestamp which is closest to deploymentTime
timestampDiff = Math.abs(metricTimestamps[0] - median);
metricTimestamps.forEach((timestamp, index) => {
if (index === 0) { // Skip first element
return;
}
smallestDiff = Math.abs(timestamp - median);
if (smallestDiff < timestampDiff) {
matchIndex = index;
timestampDiff = smallestDiff;
}
});
return matchIndex;
},
/**
* Get Graph Plotting values to render Line and Dot
*/
getGraphPlotValues(median, metrics) {
const renderData = metrics.map(v => v[1]);
const medianMetricIndex = this.getMedianMetricIndex(median, metrics);
let cx = 0;
let cy = 0;
// Find Maximum and Minimum values from `renderData` array
const maxMemory = Math.max.apply(null, renderData);
const minMemory = Math.min.apply(null, renderData);
// Find difference between extreme ends
const diff = maxMemory - minMemory;
const lineWidth = renderData.length;
// Iterate over metrics values and perform following
// 1. Find x & y co-ords for deploymentTime's memory value
// 2. Return line path against maxMemory
const linePath = renderData.map((y, x) => {
if (medianMetricIndex === x) {
cx = x;
cy = maxMemory - y;
}
return `${x} ${maxMemory - y}`;
});
return {
pathD: linePath,
pathViewBox: {
lineWidth,
diff,
},
dotX: cx,
dotY: cy,
};
},
/**
* Render Graph based on provided median and metrics values
*/
renderGraph(median, metrics) {
const { pathD, pathViewBox, dotX, dotY } = this.getGraphPlotValues(median, metrics);
// Set props and update graph on UI.
this.pathD = `M ${pathD}`;
this.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
this.dotX = dotX;
this.dotY = dotY;
},
},
mounted() {
const renderData = this.$props.metrics.map(v => v[1]);
const maxMemory = Math.max.apply(null, renderData);
const minMemory = Math.min.apply(null, renderData);
const diff = maxMemory - minMemory;
// const cx = 0;
// const cy = 0;
const lineWidth = renderData.length;
const linePath = renderData.map((y, x) => `${x} ${maxMemory - y}`);
this.pathD = `M ${linePath}`;
this.pathViewBox = `0 0 ${lineWidth} ${diff}`;
this.renderGraph(this.deploymentTime, this.metrics);
},
template: `
<div class="memory-graph-container">
<svg :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
<svg class="has-tooltip" :title="getFormattedMedian" :width="width" :height="height" xmlns="http://www.w3.org/2000/svg">
<path :d="pathD" :viewBox="pathViewBox" />
<!--<circle r="0.8" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" /> -->
<circle r="1.5" :cx="dotX" :cy="dotY" tranform="translate(0 -1)" />
</svg>
</div>
`,
Loading
Loading
.memory-graph-container {
svg {
background: $white-light;
cursor: pointer;
&:hover {
box-shadow: 0 0 4px $gray-darkest inset;
}
}
 
path {
fill: none;
stroke: $blue-500;
stroke-width: 1px;
stroke-width: 2px;
}
 
circle {
stroke: $blue-700;
fill: $blue-700;
stroke-width: 4px;
}
}
Loading
Loading
@@ -182,8 +182,7 @@
}
 
&.mr-memory-usage {
margin-top: 10px;
margin-bottom: 10px;
margin: 5px 0 10px 25px;
}
}
 
Loading
Loading
@@ -511,7 +510,12 @@
 
.mr-info-list.mr-memory-usage {
.legend {
height: 75%;
height: 65%;
top: 0;
@media (max-width: $screen-xs-max) {
height: 20px;
}
}
 
p {
Loading
Loading
@@ -731,13 +735,15 @@
}
 
.mr-memory-usage {
p.usage-info-loading {
margin-bottom: 6px;
p.usage-info-loading,
p.usage-info-unavailable,
p.usage-info-failed {
margin-bottom: 5px;
}
 
.usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
}
p.usage-info-loading .usage-info-load-spinner {
margin-right: 10px;
font-size: 16px;
}
 
@media (max-width: $screen-md-min) {
Loading
Loading
Loading
Loading
@@ -410,10 +410,10 @@ class Projects::MergeRequestsController < Projects::ApplicationController
 
metrics_url =
if can?(current_user, :read_environment, environment) && environment.has_metrics?
metrics_namespace_project_environment_path(environment.project.namespace,
environment.project,
environment,
deployment)
metrics_namespace_project_environment_deployment_path(environment.project.namespace,
environment.project,
environment,
deployment)
end
 
{
Loading
Loading
Loading
Loading
@@ -9,6 +9,7 @@ const deploymentMockData = [
name: 'review/diplo',
url: '/root/acets-review-apps/environments/15',
stop_url: '/root/acets-review-apps/environments/15/stop',
metrics_url: '/root/acets-review-apps/environments/15/deployments/1/metrics',
external_url: 'http://diplo.',
external_url_formatted: 'diplo.',
deployed_at: '2017-03-22T22:44:42.258Z',
Loading
Loading
@@ -156,6 +157,7 @@ describe('MRWidgetDeployment', () => {
expect(el.querySelector('.js-deploy-url').getAttribute('href')).toEqual(deployment.external_url);
expect(el.querySelector('.js-deploy-url').innerText).toContain(deployment.external_url_formatted);
expect(el.querySelector('.js-deploy-time').innerText).toContain(vm.formatDate(deployment.deployed_at));
expect(el.querySelector('.js-mr-memory-usage')).toBeDefined();
expect(el.querySelector('button')).toBeDefined();
});
 
Loading
Loading
@@ -165,6 +167,7 @@ describe('MRWidgetDeployment', () => {
 
Vue.nextTick(() => {
expect(el.querySelectorAll('.ci-widget').length).toEqual(3);
expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(3);
done();
});
});
Loading
Loading
@@ -176,6 +179,7 @@ describe('MRWidgetDeployment', () => {
expect(el.querySelectorAll('.js-deploy-meta').length).toEqual(0);
expect(el.querySelectorAll('.js-deploy-url').length).toEqual(0);
expect(el.querySelectorAll('.js-deploy-time').length).toEqual(0);
expect(el.querySelectorAll('.js-mr-memory-usage').length).toEqual(0);
expect(el.querySelectorAll('.button').length).toEqual(0);
done();
});
Loading
Loading
import Vue from 'vue';
import memoryUsageComponent from '~/vue_merge_request_widget/components/mr_widget_memory_usage';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
const url = '/root/acets-review-apps/environments/15/deployments/1/metrics';
const metricsMockData = {
success: true,
metrics: {
memory_values: [
{
metric: {},
values: [
[1493716685, '4.30859375'],
],
},
],
},
last_update: '2017-05-02T12:34:49.628Z',
deployment_time: 1493718485,
};
const createComponent = () => {
const Component = Vue.extend(memoryUsageComponent);
return new Component({
el: document.createElement('div'),
propsData: {
metricsUrl: url,
memoryMetrics: [],
deploymentTime: 0,
hasMetrics: false,
loadFailed: false,
loadingMetrics: true,
backOffRequestCounter: 0,
},
});
};
const messages = {
loadingMetrics: 'Loading deployment statistics.',
hasMetrics: 'Deployment memory usage:',
loadFailed: 'Failed to load deployment statistics.',
metricsUnavailable: 'Deployment statistics are not available currently.',
};
describe('MemoryUsage', () => {
let vm;
let el;
beforeEach(() => {
vm = createComponent();
el = vm.$el;
});
describe('props', () => {
it('should have props with defaults', () => {
const { metricsUrl } = memoryUsageComponent.props;
const MetricsUrlTypeClass = metricsUrl.type;
Vue.nextTick(() => {
expect(new MetricsUrlTypeClass() instanceof String).toBeTruthy();
expect(metricsUrl.required).toBeTruthy();
});
});
});
describe('data', () => {
it('should have default data', () => {
const data = memoryUsageComponent.data();
expect(Array.isArray(data.memoryMetrics)).toBeTruthy();
expect(data.memoryMetrics.length).toBe(0);
expect(typeof data.deploymentTime).toBe('number');
expect(data.deploymentTime).toBe(0);
expect(typeof data.hasMetrics).toBe('boolean');
expect(data.hasMetrics).toBeFalsy();
expect(typeof data.loadFailed).toBe('boolean');
expect(data.loadFailed).toBeFalsy();
expect(typeof data.loadingMetrics).toBe('boolean');
expect(data.loadingMetrics).toBeTruthy();
expect(typeof data.backOffRequestCounter).toBe('number');
expect(data.backOffRequestCounter).toBe(0);
});
});
describe('methods', () => {
const { metrics, deployment_time } = metricsMockData;
describe('computeGraphData', () => {
it('should populate sparkline graph', () => {
vm.computeGraphData(metrics, deployment_time);
const { hasMetrics, memoryMetrics, deploymentTime } = vm;
expect(hasMetrics).toBeTruthy();
expect(memoryMetrics.length > 0).toBeTruthy();
expect(deploymentTime).toEqual(deployment_time);
});
});
describe('loadMetrics', () => {
const returnServicePromise = () => new Promise((resolve) => {
resolve({
json() {
return metricsMockData;
},
});
});
it('should load metrics data using MRWidgetService', (done) => {
spyOn(MRWidgetService, 'fetchMetrics').and.returnValue(returnServicePromise(true));
spyOn(vm, 'computeGraphData');
vm.loadMetrics();
setTimeout(() => {
expect(MRWidgetService.fetchMetrics).toHaveBeenCalledWith(url);
expect(vm.computeGraphData).toHaveBeenCalledWith(metrics, deployment_time);
done();
}, 333);
});
});
});
describe('template', () => {
it('should render template elements correctly', () => {
expect(el.classList.contains('mr-memory-usage')).toBeTruthy();
expect(el.querySelector('.js-usage-info')).toBeDefined();
});
it('should show loading metrics message while metrics are being loaded', (done) => {
vm.loadingMetrics = true;
vm.hasMetrics = false;
vm.loadFailed = false;
Vue.nextTick(() => {
expect(el.querySelector('.js-usage-info.usage-info-loading')).toBeDefined();
expect(el.querySelector('.js-usage-info .usage-info-load-spinner')).toBeDefined();
expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadingMetrics);
done();
});
});
it('should show deployment memory usage when metrics are loaded', (done) => {
vm.loadingMetrics = false;
vm.hasMetrics = true;
vm.loadFailed = false;
Vue.nextTick(() => {
expect(el.querySelector('.memory-graph-container')).toBeDefined();
expect(el.querySelector('.js-usage-info').innerText).toContain(messages.hasMetrics);
done();
});
});
it('should show failure message when metrics loading failed', (done) => {
vm.loadingMetrics = false;
vm.hasMetrics = false;
vm.loadFailed = true;
Vue.nextTick(() => {
expect(el.querySelector('.js-usage-info.usage-info-failed')).toBeDefined();
expect(el.querySelector('.js-usage-info').innerText).toContain(messages.loadFailed);
done();
});
});
it('should show metrics unavailable message when metrics loading failed', (done) => {
vm.loadingMetrics = false;
vm.hasMetrics = false;
vm.loadFailed = false;
Vue.nextTick(() => {
expect(el.querySelector('.js-usage-info.usage-info-unavailable')).toBeDefined();
expect(el.querySelector('.js-usage-info').innerText).toContain(messages.metricsUnavailable);
done();
});
});
});
});
import Vue from 'vue';
import memoryGraphComponent from '~/vue_shared/components/memory_graph';
import { mockMetrics, mockMedian, mockMedianIndex } from './mock_data';
const defaultHeight = '25';
const defaultWidth = '100';
const createComponent = () => {
const Component = Vue.extend(memoryGraphComponent);
return new Component({
el: document.createElement('div'),
propsData: {
metrics: [],
deploymentTime: 0,
width: '',
height: '',
pathD: '',
pathViewBox: '',
dotX: '',
dotY: '',
},
});
};
describe('MemoryGraph', () => {
let vm;
let el;
beforeEach(() => {
vm = createComponent();
el = vm.$el;
});
describe('props', () => {
it('should have props with defaults', (done) => {
const { metrics, deploymentTime, width, height } = memoryGraphComponent.props;
Vue.nextTick(() => {
const typeClassMatcher = (propItem, expectedType) => {
const PropItemTypeClass = propItem.type;
expect(new PropItemTypeClass() instanceof expectedType).toBeTruthy();
expect(propItem.required).toBeTruthy();
};
typeClassMatcher(metrics, Array);
typeClassMatcher(deploymentTime, Number);
typeClassMatcher(width, String);
typeClassMatcher(height, String);
done();
});
});
});
describe('data', () => {
it('should have default data', () => {
const data = memoryGraphComponent.data();
const dataValidator = (dataItem, expectedType, defaultVal) => {
expect(typeof dataItem).toBe(expectedType);
expect(dataItem).toBe(defaultVal);
};
dataValidator(data.pathD, 'string', '');
dataValidator(data.pathViewBox, 'string', '');
dataValidator(data.dotX, 'string', '');
dataValidator(data.dotY, 'string', '');
});
});
describe('computed', () => {
describe('getFormattedMedian', () => {
it('should show human readable median value based on provided median timestamp', () => {
vm.deploymentTime = mockMedian;
const formattedMedian = vm.getFormattedMedian;
expect(formattedMedian.indexOf('Deployed') > -1).toBeTruthy();
expect(formattedMedian.indexOf('ago') > -1).toBeTruthy();
});
});
});
describe('methods', () => {
describe('getMedianMetricIndex', () => {
it('should return index of closest metric timestamp to that of median', () => {
const matchingIndex = vm.getMedianMetricIndex(mockMedian, mockMetrics);
expect(matchingIndex).toBe(mockMedianIndex);
});
});
describe('getGraphPlotValues', () => {
it('should return Object containing values to plot graph', () => {
const plotValues = vm.getGraphPlotValues(mockMedian, mockMetrics);
expect(plotValues.pathD).toBeDefined();
expect(Array.isArray(plotValues.pathD)).toBeTruthy();
expect(plotValues.pathViewBox).toBeDefined();
expect(typeof plotValues.pathViewBox).toBe('object');
expect(plotValues.dotX).toBeDefined();
expect(typeof plotValues.dotX).toBe('number');
expect(plotValues.dotY).toBeDefined();
expect(typeof plotValues.dotY).toBe('number');
});
});
});
describe('template', () => {
it('should render template elements correctly', () => {
expect(el.classList.contains('memory-graph-container')).toBeTruthy();
expect(el.querySelector('svg')).toBeDefined();
});
it('should render graph when renderGraph is called internally', (done) => {
const { pathD, pathViewBox, dotX, dotY } = vm.getGraphPlotValues(mockMedian, mockMetrics);
vm.height = defaultHeight;
vm.width = defaultWidth;
vm.pathD = `M ${pathD}`;
vm.pathViewBox = `0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`;
vm.dotX = dotX;
vm.dotY = dotY;
Vue.nextTick(() => {
const svgEl = el.querySelector('svg');
expect(svgEl).toBeDefined();
expect(svgEl.getAttribute('height')).toBe(defaultHeight);
expect(svgEl.getAttribute('width')).toBe(defaultWidth);
const pathEl = el.querySelector('path');
expect(pathEl).toBeDefined();
expect(pathEl.getAttribute('d')).toBe(`M ${pathD}`);
expect(pathEl.getAttribute('viewBox')).toBe(`0 0 ${pathViewBox.lineWidth} ${pathViewBox.diff}`);
const circleEl = el.querySelector('circle');
expect(circleEl).toBeDefined();
expect(circleEl.getAttribute('r')).toBe('1.5');
expect(circleEl.getAttribute('tranform')).toBe('translate(0 -1)');
expect(circleEl.getAttribute('cx')).toBe(`${dotX}`);
expect(circleEl.getAttribute('cy')).toBe(`${dotY}`);
done();
});
});
});
});
/* eslint-disable */
export const mockMetrics = [
[1493716685, '4.30859375'],
[1493716745, '4.30859375'],
[1493716805, '4.30859375'],
[1493716865, '4.30859375'],
[1493716925, '4.30859375'],
[1493716985, '4.30859375'],
[1493717045, '4.30859375'],
[1493717105, '4.30859375'],
[1493717165, '4.30859375'],
[1493717225, '4.30859375'],
[1493717285, '4.30859375'],
[1493717345, '4.30859375'],
[1493717405, '4.30859375'],
[1493717465, '4.30859375'],
[1493717525, '4.30859375'],
[1493717585, '4.30859375'],
[1493717645, '4.30859375'],
[1493717705, '4.30859375'],
[1493717765, '4.30859375'],
[1493717825, '4.30859375'],
[1493717885, '4.30859375'],
[1493717945, '4.30859375'],
[1493718005, '4.30859375'],
[1493718065, '4.30859375'],
[1493718125, '4.30859375'],
[1493718185, '4.30859375'],
[1493718245, '4.30859375'],
[1493718305, '4.234375'],
[1493718365, '4.234375'],
[1493718425, '4.234375'],
[1493718485, '4.234375'],
[1493718545, '4.243489583333333'],
[1493718605, '4.2109375'],
[1493718665, '4.2109375'],
[1493718725, '4.2109375'],
[1493718785, '4.26171875'],
[1493718845, '4.26171875'],
[1493718905, '4.26171875'],
[1493718965, '4.26171875'],
[1493719025, '4.26171875'],
[1493719085, '4.26171875'],
[1493719145, '4.26171875'],
[1493719205, '4.26171875'],
[1493719265, '4.26171875'],
[1493719325, '4.26171875'],
[1493719385, '4.26171875'],
[1493719445, '4.26171875'],
[1493719505, '4.26171875'],
[1493719565, '4.26171875'],
[1493719625, '4.26171875'],
[1493719685, '4.26171875'],
[1493719745, '4.26171875'],
[1493719805, '4.26171875'],
[1493719865, '4.26171875'],
[1493719925, '4.26171875'],
[1493719985, '4.26171875'],
[1493720045, '4.26171875'],
[1493720105, '4.26171875'],
[1493720165, '4.26171875'],
[1493720225, '4.26171875'],
[1493720285, '4.26171875'],
];
export const mockMedian = 1493718485;
export const mockMedianIndex = 30;
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment