Commit 5354c293 authored by Fatih Acet's avatar Fatih Acet

Merge branch '26944-prometheus-memory-sparkline' into 'master'

Add Prometheus memory sparkline to MR widget

Closes #26944

See merge request !11209
parents a3607aa4 f6600616
......@@ -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>
......
......@@ -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() {
......@@ -14,6 +12,7 @@ export default {
// memoryFrom: 0,
// memoryTo: 0,
memoryMetrics: [],
deploymentTime: 0,
hasMetrics: false,
loadFailed: false,
loadingMetrics: true,
......@@ -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) {
......@@ -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>
......
......@@ -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 },
},
......@@ -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>
`,
......
.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;
}
}
......@@ -182,8 +182,7 @@
}
&.mr-memory-usage {
margin-top: 10px;
margin-bottom: 10px;
margin: 5px 0 10px 25px;
}
}
......@@ -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 {
......@@ -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) {
......
......@@ -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
{
......
......@@ -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',
......@@ -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();
});
......@@ -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();
});
});
......@@ -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();
});
......
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;
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