Commit fe96152d authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'jivl-summary-statistics-prometheus-dashboard' into 'master'

Add summary statistics to the prometheus dashboard

Closes #43973

See merge request gitlab-org/gitlab-ce!17921
parents 9e3cdc02 d303b5ba
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
* @param {String} text * @param {String} text
* @returns {String} * @returns {String}
*/ */
export const addDelimiter = text => (text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text); export const addDelimiter = text =>
(text ? text.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') : text);
/** /**
* Returns '99+' for numbers bigger than 99. * Returns '99+' for numbers bigger than 99.
...@@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count); ...@@ -22,7 +23,8 @@ export const highCountTrim = count => (count > 99 ? '99+' : count);
* @param {String} string * @param {String} string
* @requires {String} * @requires {String}
*/ */
export const humanize = string => string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1); export const humanize = string =>
string.charAt(0).toUpperCase() + string.replace(/_/g, ' ').slice(1);
/** /**
* Adds an 's' to the end of the string when count is bigger than 0 * Adds an 's' to the end of the string when count is bigger than 0
...@@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase(); ...@@ -53,7 +55,7 @@ export const slugify = str => str.trim().toLowerCase();
* @param {Number} maxLength * @param {Number} maxLength
* @returns {String} * @returns {String}
*/ */
export const truncate = (string, maxLength) => `${string.substr(0, (maxLength - 3))}...`; export const truncate = (string, maxLength) => `${string.substr(0, maxLength - 3)}...`;
/** /**
* Capitalizes first character * Capitalizes first character
...@@ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re ...@@ -80,3 +82,15 @@ export const stripHtml = (string, replace = '') => string.replace(/<[^>]*>/g, re
* @param {*} string * @param {*} string
*/ */
export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase()); export const convertToCamelCase = string => string.replace(/(_\w)/g, s => s[1].toUpperCase());
/**
* Converts a sentence to lower case from the second word onwards
* e.g. Hello World => Hello world
*
* @param {*} string
*/
export const convertToSentenceCase = string => {
const splitWord = string.split(' ').map((word, index) => (index > 0 ? word.toLowerCase() : word));
return splitWord.join(' ');
};
<script> <script>
import { scaleLinear, scaleTime } from 'd3-scale'; import { scaleLinear, scaleTime } from 'd3-scale';
import { axisLeft, axisBottom } from 'd3-axis'; import { axisLeft, axisBottom } from 'd3-axis';
import _ from 'underscore';
import { max, extent } from 'd3-array'; import { max, extent } from 'd3-array';
import { select } from 'd3-selection'; import { select } from 'd3-selection';
import GraphAxis from './graph/axis.vue';
import GraphLegend from './graph/legend.vue'; import GraphLegend from './graph/legend.vue';
import GraphFlag from './graph/flag.vue'; import GraphFlag from './graph/flag.vue';
import GraphDeployment from './graph/deployment.vue'; import GraphDeployment from './graph/deployment.vue';
...@@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select } ...@@ -18,10 +20,11 @@ const d3 = { scaleLinear, scaleTime, axisLeft, axisBottom, max, extent, select }
export default { export default {
components: { components: {
GraphLegend, GraphAxis,
GraphFlag, GraphFlag,
GraphDeployment, GraphDeployment,
GraphPath, GraphPath,
GraphLegend,
}, },
mixins: [MonitoringMixin], mixins: [MonitoringMixin],
props: { props: {
...@@ -138,7 +141,7 @@ export default { ...@@ -138,7 +141,7 @@ export default {
this.legendTitle = query.label || 'Average'; this.legendTitle = query.label || 'Average';
this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right; this.graphWidth = this.$refs.baseSvg.clientWidth - this.margin.left - this.margin.right;
this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom; this.graphHeight = this.graphHeight - this.margin.top - this.margin.bottom;
this.baseGraphHeight = this.graphHeight; this.baseGraphHeight = this.graphHeight - 50;
this.baseGraphWidth = this.graphWidth; this.baseGraphWidth = this.graphWidth;
// pixel offsets inside the svg and outside are not 1:1 // pixel offsets inside the svg and outside are not 1:1
...@@ -177,10 +180,8 @@ export default { ...@@ -177,10 +180,8 @@ export default {
this.graphHeightOffset, this.graphHeightOffset,
); );
if (!this.showLegend) { if (_.findWhere(this.timeSeries, { renderCanary: true })) {
this.baseGraphHeight -= 50; this.timeSeries = this.timeSeries.map(series => ({ ...series, renderCanary: true }));
} else if (this.timeSeries.length > 3) {
this.baseGraphHeight = this.baseGraphHeight += (this.timeSeries.length - 3) * 20;
} }
const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]); const axisXScale = d3.scaleTime().range([0, this.graphWidth - 70]);
...@@ -251,17 +252,13 @@ export default { ...@@ -251,17 +252,13 @@ export default {
class="y-axis" class="y-axis"
transform="translate(70, 20)" transform="translate(70, 20)"
/> />
<graph-legend <graph-axis
:graph-width="graphWidth" :graph-width="graphWidth"
:graph-height="graphHeight" :graph-height="graphHeight"
:margin="margin" :margin="margin"
:measurements="measurements" :measurements="measurements"
:legend-title="legendTitle"
:y-axis-label="yAxisLabel" :y-axis-label="yAxisLabel"
:time-series="timeSeries"
:unit-of-display="unitOfDisplay" :unit-of-display="unitOfDisplay"
:current-data-index="currentDataIndex"
:show-legend-group="showLegend"
/> />
<svg <svg
class="graph-data" class="graph-data"
...@@ -306,5 +303,10 @@ export default { ...@@ -306,5 +303,10 @@ export default {
:deployment-flag-data="deploymentFlagData" :deployment-flag-data="deploymentFlagData"
/> />
</div> </div>
<graph-legend
v-if="showLegend"
:legend-title="legendTitle"
:time-series="timeSeries"
/>
</div> </div>
</template> </template>
<script>
import { convertToSentenceCase } from '~/lib/utils/text_utility';
import { s__ } from '~/locale';
export default {
props: {
graphWidth: {
type: Number,
required: true,
},
graphHeight: {
type: Number,
required: true,
},
margin: {
type: Object,
required: true,
},
measurements: {
type: Object,
required: true,
},
yAxisLabel: {
type: String,
required: true,
},
unitOfDisplay: {
type: String,
required: true,
},
},
data() {
return {
yLabelWidth: 0,
yLabelHeight: 0,
};
},
computed: {
textTransform() {
const yCoordinate =
(this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset) /
2 || 0;
return `translate(15, ${yCoordinate}) rotate(-90)`;
},
rectTransform() {
const yCoordinate =
(this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset) /
2 +
this.yLabelWidth / 2 || 0;
return `translate(0, ${yCoordinate}) rotate(-90)`;
},
xPosition() {
return (
(this.graphWidth + this.measurements.axisLabelLineOffset) / 2 -
this.margin.right || 0
);
},
yPosition() {
return (
this.graphHeight -
this.margin.top +
this.measurements.axisLabelLineOffset || 0
);
},
yAxisLabelSentenceCase() {
return `${convertToSentenceCase(this.yAxisLabel)} (${this.unitOfDisplay})`;
},
timeString() {
return s__('PrometheusDashboard|Time');
},
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
},
};
</script>
<template>
<g class="axis-label-container">
<line
class="label-x-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
:y1="yPosition"
:x2="graphWidth + 20"
:y2="yPosition"
/>
<line
class="label-y-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
y1="0"
:x2="10"
:y2="yPosition"
/>
<rect
class="rect-axis-text"
:transform="rectTransform"
:width="yLabelWidth"
:height="yLabelHeight"
/>
<text
class="label-axis-text y-label-text"
text-anchor="middle"
:transform="textTransform"
ref="ylabel"
>
{{ yAxisLabelSentenceCase }}
</text>
<rect
class="rect-axis-text"
:x="xPosition + 60"
:y="graphHeight - 80"
width="35"
height="50"
/>
<text
class="label-axis-text x-label-text"
:x="xPosition + 60"
:y="yPosition"
dy=".35em"
>
{{ timeString }}
</text>
</g>
</template>
<script> <script>
import { dateFormat, timeFormat } from '../../utils/date_time_formatters'; import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
import { formatRelevantDigits } from '../../../lib/utils/number_utils'; import { formatRelevantDigits } from '../../../lib/utils/number_utils';
import icon from '../../../vue_shared/components/icon.vue'; import Icon from '../../../vue_shared/components/icon.vue';
import TrackLine from './track_line.vue';
export default { export default {
components: { components: {
icon, Icon,
TrackLine,
}, },
props: { props: {
currentXCoordinate: { currentXCoordinate: {
...@@ -107,11 +109,6 @@ export default { ...@@ -107,11 +109,6 @@ export default {
} }
return `series ${index + 1}`; return `series ${index + 1}`;
}, },
strokeDashArray(type) {
if (type === 'dashed') return '6, 3';
if (type === 'dotted') return '3, 3';
return null;
},
}, },
}; };
</script> </script>
...@@ -160,28 +157,13 @@ export default { ...@@ -160,28 +157,13 @@ export default {
</div> </div>
</div> </div>
<div class="popover-content"> <div class="popover-content">
<table> <table class="prometheus-table">
<tr <tr
v-for="(series, index) in timeSeries" v-for="(series, index) in timeSeries"
:key="index" :key="index"
> >
<td> <track-line :track="series"/>
<svg <td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
width="15"
height="6"
>
<line
:stroke="series.lineColor"
:stroke-dasharray="strokeDashArray(series.lineStyle)"
stroke-width="4"
x1="0"
x2="15"
y1="2"
y2="2"
/>
</svg>
</td>
<td>{{ seriesMetricLabel(index, series) }}</td>
<td> <td>
<strong>{{ seriesMetricValue(series) }}</strong> <strong>{{ seriesMetricValue(series) }}</strong>
</td> </td>
......
<script> <script>
import { formatRelevantDigits } from '../../../lib/utils/number_utils'; import TrackLine from './track_line.vue';
import TrackInfo from './track_info.vue';
export default { export default {
props: { components: {
graphWidth: { TrackLine,
type: Number, TrackInfo,
required: true,
},
graphHeight: {
type: Number,
required: true,
},
margin: {
type: Object,
required: true,
},
measurements: {
type: Object,
required: true,
}, },
props: {
legendTitle: { legendTitle: {
type: String, type: String,
required: true, required: true,
}, },
yAxisLabel: {
type: String,
required: true,
},
timeSeries: { timeSeries: {
type: Array, type: Array,
required: true, required: true,
}, },
unitOfDisplay: {
type: String,
required: true,
},
currentDataIndex: {
type: Number,
required: true,
},
showLegendGroup: {
type: Boolean,
required: false,
default: true,
},
}, },
data() { methods: {
isStable(track) {
return { return {
yLabelWidth: 0, 'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
yLabelHeight: 0,
seriesXPosition: 0,
metricUsageXPosition: 0,
}; };
}, },
computed: {
textTransform() {
const yCoordinate =
(this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 || 0;
return `translate(15, ${yCoordinate}) rotate(-90)`;
},
rectTransform() {
const yCoordinate =
(this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset) / 2 +
this.yLabelWidth / 2 || 0;
return `translate(0, ${yCoordinate}) rotate(-90)`;
},
xPosition() {
return (this.graphWidth + this.measurements.axisLabelLineOffset) / 2 - this.margin.right || 0;
},
yPosition() {
return this.graphHeight - this.margin.top + this.measurements.axisLabelLineOffset || 0;
},
},
mounted() {
this.$nextTick(() => {
const bbox = this.$refs.ylabel.getBBox();
this.metricUsageXPosition = 0;
this.seriesXPosition = 0;
if (this.$refs.legendTitleSvg != null) {
this.seriesXPosition = this.$refs.legendTitleSvg[0].getBBox().width;
}
if (this.$refs.seriesTitleSvg != null) {
this.metricUsageXPosition = this.$refs.seriesTitleSvg[0].getBBox().width;
}
this.yLabelWidth = bbox.width + 10; // Added some padding
this.yLabelHeight = bbox.height + 5;
});
},
methods: {
translateLegendGroup(index) {
return `translate(0, ${12 * index})`;
},
formatMetricUsage(series) {
const value =
series.values[this.currentDataIndex] && series.values[this.currentDataIndex].value;
if (isNaN(value)) {
return '-';
}
return `${formatRelevantDigits(value)} ${this.unitOfDisplay}`;
},
createSeriesString(index, series) {
if (series.metricTag) {
return `${series.metricTag} ${this.formatMetricUsage(series)}`;
}
return `${this.legendTitle} series ${index + 1} ${this.formatMetricUsage(series)}`;
},
strokeDashArray(type) {
if (type === 'dashed') return '6, 3';
if (type === 'dotted') return '3, 3';
return null;
},
}, },
}; };
</script> </script>
<template> <template>
<g class="axis-label-container"> <div class="prometheus-graph-legends prepend-left-10">
<line <table class="prometheus-table">
class="label-x-axis-line" <tr
stroke="#000000"
stroke-width="1"
x1="10"
:y1="yPosition"
:x2="graphWidth + 20"
:y2="yPosition"
/>
<line
class="label-y-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
y1="0"
:x2="10"
:y2="yPosition"
/>
<rect
class="rect-axis-text"
:transform="rectTransform"
:width="yLabelWidth"
:height="yLabelHeight"
/>
<text
class="label-axis-text y-label-text"
text-anchor="middle"
:transform="textTransform"
ref="ylabel"
>
{{ yAxisLabel }}
</text>
<rect
class="rect-axis-text"
:x="xPosition + 60"
:y="graphHeight - 80"
width="35"
height="50"
/>
<text
class="label-axis-text x-label-text"
:x="xPosition + 60"
:y="yPosition"
dy=".35em"
>
Time
</text>
<template v-if="showLegendGroup">
<g
class="legend-group"
v-for="(series, index) in timeSeries" v-for="(series, index) in timeSeries"
:key="index" :key="index"
:transform="translateLegendGroup(index)" v-if="series.shouldRenderLegend"
:class="isStable(series)"
> >
<line <td>
:stroke="series.lineColor" <strong v-if="series.renderCanary">{{ series.trackName }}</strong>
:stroke-width="measurements.legends.height" </td>
:stroke-dasharray="strokeDashArray(series.lineStyle)" <track-line :track="series" />
:x1="measurements.legends.offsetX" <td
:x2="measurements.legends.offsetX + measurements.legends.width"
:y1="graphHeight - measurements.legends.offsetY"
:y2="graphHeight - measurements.legends.offsetY"
/>
<text
v-if="timeSeries.length > 1"
class="legend-metric-title" class="legend-metric-title"
ref="legendTitleSvg" v-if="timeSeries.length > 1">
x="38" <track-info
:y="graphHeight - 30" :track="series"
> v-if="series.metricTag" />
{{ createSeriesString(index, series) }} <track-info
</text>
<text
v-else v-else
:track="series">
<strong>{{ legendTitle }}</strong> series {{ index + 1 }}
</track-info>
</td>
<td v-else>
<track-info :track="series">
<strong>{{ legendTitle }}</strong>
</track-info>
</td>
<template v-for="(track, trackIndex) in series.tracksLegend">
<track-line
:track="track"
:key="`track-line-${trackIndex}`"/>
<td :key="`track-info-${trackIndex}`">
<track-info
class="legend-metric-title" class="legend-metric-title"
ref="legendTitleSvg" :track="track" />
x="38" </td>
:y="graphHeight - 30"
>
{{ legendTitle }} {{ formatMetricUsage(series) }}
</text>
</g>
</template> </template>
</g> </tr>
</table>
</div>
</template> </template>
<script>
import { formatRelevantDigits } from '~/lib/utils/number_utils';
export default {
name: 'TrackInfo',
props: {
track: {
type: Object,
required: true,
},
},
computed: {
summaryMetrics() {
return `Avg: ${formatRelevantDigits(this.track.average)} · Max: ${formatRelevantDigits(
this.track.max,
)}`;
},
},
};
</script>
<template>
<span>
<slot>
<strong> {{ track.metricTag }} </strong>
</slot>
{{ summaryMetrics }}
</span>
</template>
<script>
export default {
name: 'TrackLine',
props: {
track: {
type: Object,
required: true,
},
},
computed: {
stylizedLine() {
if (this.track.lineStyle === 'dashed') return '6, 3';
if (this.track.lineStyle === 'dotted') return '3, 3';
return null;
},
},
};
</script>
<template>
<td>
<svg
width="15"
height="6">
<line
:stroke-dasharray="stylizedLine"
:stroke="track.lineColor"
stroke-width="4"
:x1="0"
:x2="15"
:y1="2"
:y2="2"
/>
</svg>
</td>
</template>
import _ from 'underscore'; import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale'; import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape'; import { line, area, curveLinear } from 'd3-shape';
import { extent, max } from 'd3-array'; import { extent, max, sum } from 'd3-array';
import { timeMinute } from 'd3-time'; import { timeMinute } from 'd3-time';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
const d3 = { scaleLinear, scaleTime, line, area, curveLinear, extent, max, timeMinute };
const d3 = {
scaleLinear,
scaleTime,
line,
area,
curveLinear,
extent,
max,
timeMinute,
sum,
};
const defaultColorPalette = { const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'], blue: ['#1f78d1', '#8fbce8'],
...@@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted']; ...@@ -20,6 +31,8 @@ const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) { function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom, yDom, lineStyle) {
let usedColors = []; let usedColors = [];
let renderCanary = false;
const timeSeriesParsed = [];
function pickColor(name) { function pickColor(name) {
let pick; let pick;
...@@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom ...@@ -38,16 +51,23 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
return defaultColorPalette[pick]; return defaultColorPalette[pick];
} }
return query.result.map((timeSeries, timeSeriesNumber) => { query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = ''; let metricTag = '';
let lineColor = ''; let lineColor = '';
let areaColor = ''; let areaColor = '';
let shouldRenderLegend = true;
const timeSeriesValues = timeSeries.values.map(d => d.value);
const maximumValue = d3.max(timeSeriesValues);
const accum = d3.sum(timeSeriesValues);
const trackName = capitalizeFirstCharacter(query.track ? query.track : 'Stable');
if (trackName === 'Canary') {
renderCanary = true;
}
const timeSeriesScaleX = d3.scaleTime() const timeSeriesScaleX = d3.scaleTime().range([0, graphWidth - 70]);
.range([0, graphWidth - 70]);
const timeSeriesScaleY = d3.scaleLinear() const timeSeriesScaleY = d3.scaleLinear().range([graphHeight - graphHeightOffset, 0]);
.range([graphHeight - graphHeightOffset, 0]);
timeSeriesScaleX.domain(xDom); timeSeriesScaleX.domain(xDom);
timeSeriesScaleX.ticks(d3.timeMinute, 60); timeSeriesScaleX.ticks(d3.timeMinute, 60);
...@@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom ...@@ -55,13 +75,15 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
const defined = d => !isNaN(d.value) && d.value != null; const defined = d => !isNaN(d.value) && d.value != null;
const lineFunction = d3.line() const lineFunction = d3
.line()
.defined(defined) .defined(defined)
.curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate .curve(d3.curveLinear) // d3 v4 uses curbe instead of interpolate
.x(d => timeSeriesScaleX(d.time)) .x(d => timeSeriesScaleX(d.time))
.y(d => timeSeriesScaleY(d.value)); .y(d => timeSeriesScaleY(d.value));
const areaFunction = d3.area() const areaFunction = d3
.area()
.defined(defined) .defined(defined)
.curve(d3.curveLinear) .curve(d3.curveLinear)
.x(d => timeSeriesScaleX(d.time)) .x(d => timeSeriesScaleX(d.time))
...@@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom ...@@ -69,38 +91,62 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom
.y1(d => timeSeriesScaleY(d.value)); .y1(d => timeSeriesScaleY(d.value));
const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]]; const timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
const seriesCustomizationData = query.series != null && const seriesCustomizationData =
_.findWhere(query.series[0].when, { value: timeSeriesMetricLabel }); query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
if (seriesCustomizationData) { if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel; metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color); [lineColor, areaColor] = pickColor(seriesCustomizationData.color);
shouldRenderLegend = false;
} else { } else {
metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`; metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor(); [lineColor, areaColor] = pickColor();
if (timeSeriesParsed.length > 1) {
shouldRenderLegend = false;
}
} }
if (query.track) { if (!shouldRenderLegend) {
metricTag += ` - ${query.track}`; if (!timeSeriesParsed[0].tracksLegend) {
timeSeriesParsed[0].tracksLegend = [];
}
timeSeriesParsed[0].tracksLegend.push({
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle,
lineColor,
metricTag,
});
} }
return { timeSeriesParsed.push({
linePath: lineFunction(timeSeries.values), linePath: lineFunction(timeSeries.values),
areaPath: areaFunction(timeSeries.values), areaPath: areaFunction(timeSeries.values),
timeSeriesScaleX, timeSeriesScaleX,
values: timeSeries.values, values: timeSeries.values,
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle, lineStyle,
lineColor, lineColor,
areaColor, areaColor,
metricTag, metricTag,
}; trackName,
shouldRenderLegend,
renderCanary,
}); });
});
return timeSeriesParsed;
} }
export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) { export default function createTimeSeries(queries, graphWidth, graphHeight, graphHeightOffset) {
const allValues = queries.reduce((allQueryResults, query) => allQueryResults.concat( const allValues = queries.reduce(
(allQueryResults, query) =>
allQueryResults.concat(
query.result.reduce((allResults, result) => allResults.concat(result.values), []), query.result.reduce((allResults, result) => allResults.concat(result.values), []),
), []); ),
[],
);
const xDom = d3.extent(allValues, d => d.time); const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))]; const yDom = [0, d3.max(allValues.map(d => d.value))];
......
...@@ -767,3 +767,8 @@ $border-color-settings: #e1e1e1; ...@@ -767,3 +767,8 @@ $border-color-settings: #e1e1e1;
Modals Modals
*/ */
$modal-body-height: 134px; $modal-body-height: 134px;
/*
Prometheus
*/
$prometheus-table-row-highlight-color: $theme-gray-100;
...@@ -273,21 +273,6 @@ ...@@ -273,21 +273,6 @@
line-height: 1.2; line-height: 1.2;
} }
table {
border-collapse: collapse;
padding: 0;
margin: 0;
}
td {
vertical-align: middle;
+ td {
padding-left: 5px;
vertical-align: top;
}
}
.deploy-meta-content { .deploy-meta-content {
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
...@@ -323,6 +308,26 @@ ...@@ -323,6 +308,26 @@
} }
} }
.prometheus-table {
border-collapse: collapse;
padding: 0;
margin: 0;
td {
vertical-align: middle;
+ td {
padding-left: 5px;
vertical-align: top;
}
}
.legend-metric-title {
font-size: 12px;
vertical-align: middle;
}
}
.prometheus-svg-container { .prometheus-svg-container {
position: relative; position: relative;
height: 0; height: 0;
...@@ -330,8 +335,7 @@ ...@@ -330,8 +335,7 @@
padding: 0; padding: 0;
padding-bottom: 100%; padding-bottom: 100%;
.text-metric-usage, .text-metric-usage {
.legend-metric-title {
fill: $black; fill: $black;
font-weight: $gl-font-weight-normal; font-weight: $gl-font-weight-normal;
font-size: 12px; font-size: 12px;
...@@ -374,10 +378,6 @@ ...@@ -374,10 +378,6 @@
} }
} }
.text-metric-title {
font-size: 12px;
}
.y-label-text, .y-label-text,
.x-label-text { .x-label-text {
fill: $gray-darkest; fill: $gray-darkest;
...@@ -414,3 +414,7 @@ ...@@ -414,3 +414,7 @@
} }
} }
} }
.prometheus-table-row-highlight {
background-color: $prometheus-table-row-highlight-color;
}
---
title: Add average and maximum summary statistics to the prometheus dashboard
merge_request: 17921
author:
type: changed
...@@ -26,7 +26,7 @@ ...@@ -26,7 +26,7 @@
weight: 1 weight: 1
queries: queries:
- query_range: 'avg(nginx_upstream_response_msecs_avg{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"})' - query_range: 'avg(nginx_upstream_response_msecs_avg{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"})'
label: Average label: Pod average
unit: ms unit: ms
- title: "HTTP Error Rate" - title: "HTTP Error Rate"
y_label: "HTTP 500 Errors / Sec" y_label: "HTTP 500 Errors / Sec"
...@@ -146,7 +146,7 @@ ...@@ -146,7 +146,7 @@
weight: 1 weight: 1
queries: queries:
- query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024' - query_range: 'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024'
label: Average label: Pod average
unit: MB unit: MB
- title: "CPU Usage" - title: "CPU Usage"
y_label: "Cores per Pod" y_label: "Cores per Pod"
...@@ -155,5 +155,5 @@ ...@@ -155,5 +155,5 @@
weight: 1 weight: 1
queries: queries:
- query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (pod_name))' - query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job) / count(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-([^c].*|c([^a]|a([^n]|n([^a]|a([^r]|r[^y])))).*|)-(.*)",namespace="%{kube_namespace}"}[15m])) by (pod_name))'
label: Average label: Pod average
unit: "cores" unit: "cores"
\ No newline at end of file
...@@ -65,11 +65,15 @@ describe('text_utility', () => { ...@@ -65,11 +65,15 @@ describe('text_utility', () => {
describe('stripHtml', () => { describe('stripHtml', () => {
it('replaces html tag with the default replacement', () => { it('replaces html tag with the default replacement', () => {
expect(textUtils.stripHtml('This is a text with <p>html</p>.')).toEqual('This is a text with html.'); expect(textUtils.stripHtml('This is a text with <p>html</p>.')).toEqual(
'This is a text with html.',
);
}); });
it('replaces html tags with the provided replacement', () => { it('replaces html tags with the provided replacement', () => {
expect(textUtils.stripHtml('This is a text with <p>html</p>.', ' ')).toEqual('This is a text with html .'); expect(textUtils.stripHtml('This is a text with <p>html</p>.', ' ')).toEqual(
'This is a text with html .',
);
}); });
}); });
...@@ -78,4 +82,10 @@ describe('text_utility', () => { ...@@ -78,4 +82,10 @@ describe('text_utility', () => {
expect(textUtils.convertToCamelCase('snake_case')).toBe('snakeCase'); expect(textUtils.convertToCamelCase('snake_case')).toBe('snakeCase');
}); });
}); });
describe('convertToSentenceCase', () => {
it('converts Sentence Case to Sentence case', () => {
expect(textUtils.convertToSentenceCase('Hello World')).toBe('Hello world');
});
});
}); });
import Vue from 'vue';
import GraphAxis from '~/monitoring/components/graph/axis.vue';
import measurements from '~/monitoring/utils/measurements';
const createComponent = propsData => {
const Component = Vue.extend(GraphAxis);
return new Component({
propsData,
}).$mount();
};
const defaultValuesComponent = {
graphWidth: 500,
graphHeight: 300,
graphHeightOffset: 120,
margin: measurements.large.margin,
measurements: measurements.large,
yAxisLabel: 'Values',
unitOfDisplay: 'MB',
};
function getTextFromNode(component, selector) {
return component.$el.querySelector(selector).firstChild.nodeValue.trim();
}
describe('Axis', () => {
describe('Computed props', () => {
it('textTransform', () => {
const component = createComponent(defaultValuesComponent);
expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
});
it('xPosition', () => {
const component = createComponent(defaultValuesComponent);
expect(component.xPosition).toEqual(180);
});
it('yPosition', () => {
const component = createComponent(defaultValuesComponent);
expect(component.yPosition).toEqual(240);
});
it('rectTransform', () => {
const component = createComponent(defaultValuesComponent);
expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)');
});
});
it('has 2 rect-axis-text rect svg elements', () => {
const component = createComponent(defaultValuesComponent);
expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
});
it('contains text to signal the usage, title and time with multiple time series', () => {
const component = createComponent(defaultValuesComponent);
expect(getTextFromNode(component, '.y-label-text')).toEqual('Values (MB)');
});
});
import Vue from 'vue'; import Vue from 'vue';
import GraphLegend from '~/monitoring/components/graph/legend.vue'; import GraphLegend from '~/monitoring/components/graph/legend.vue';
import measurements from '~/monitoring/utils/measurements';
import createTimeSeries from '~/monitoring/utils/multiple_time_series'; import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data'; import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
const createComponent = (propsData) => {
const Component = Vue.extend(GraphLegend);
return new Component({
propsData,
}).$mount();
};
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const defaultValuesComponent = { const defaultValuesComponent = {};
graphWidth: 500,
graphHeight: 300,
graphHeightOffset: 120,
margin: measurements.large.margin,
measurements: measurements.large,
areaColorRgb: '#f0f0f0',
legendTitle: 'Title',
yAxisLabel: 'Values',
metricUsage: 'Value',
unitOfDisplay: 'Req/Sec',
currentDataIndex: 0,
};
const timeSeries = createTimeSeries(convertedMetrics[0].queries, const timeSeries = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
defaultValuesComponent.graphWidth, defaultValuesComponent.graphHeight,
defaultValuesComponent.graphHeightOffset);
defaultValuesComponent.timeSeries = timeSeries; defaultValuesComponent.timeSeries = timeSeries;
function getTextFromNode(component, selector) { describe('Legend Component', () => {
return component.$el.querySelector(selector).firstChild.nodeValue.trim(); let vm;
} let Legend;
describe('GraphLegend', () => {
describe('Computed props', () => {
it('textTransform', () => {
const component = createComponent(defaultValuesComponent);
expect(component.textTransform).toContain('translate(15, 120) rotate(-90)');
});
it('xPosition', () => {
const component = createComponent(defaultValuesComponent);
expect(component.xPosition).toEqual(180);
});
it('yPosition', () => {
const component = createComponent(defaultValuesComponent);
expect(component.yPosition).toEqual(240);
});
it('rectTransform', () => {
const component = createComponent(defaultValuesComponent);
expect(component.rectTransform).toContain('translate(0, 120) rotate(-90)'); beforeEach(() => {
}); Legend = Vue.extend(GraphLegend);
}); });
describe('methods', () => { describe('View', () => {
it('translateLegendGroup should only change Y direction', () => { beforeEach(() => {
const component = createComponent(defaultValuesComponent); vm = mountComponent(Legend, {
legendTitle: 'legend',
const translatedCoordinate = component.translateLegendGroup(1); timeSeries,
expect(translatedCoordinate.indexOf('translate(0, ')).not.toEqual(-1); currentDataIndex: 0,
}); unitOfDisplay: 'Req/Sec',
it('formatMetricUsage should contain the unit of display and the current value selected via "currentDataIndex"', () => {
const component = createComponent(defaultValuesComponent);
const formattedMetricUsage = component.formatMetricUsage(timeSeries[0]);
const valueFromSeries = timeSeries[0].values[component.currentDataIndex].value;
expect(formattedMetricUsage.indexOf(component.unitOfDisplay)).not.toEqual(-1);
expect(formattedMetricUsage.indexOf(valueFromSeries)).not.toEqual(-1);
});
}); });
it('has 2 rect-axis-text rect svg elements', () => {
const component = createComponent(defaultValuesComponent);
expect(component.$el.querySelectorAll('.rect-axis-text').length).toEqual(2);
}); });
it('contains text to signal the usage, title and time with multiple time series', () => { it('should render the usage, title and time with multiple time series', () => {
const component = createComponent(defaultValuesComponent); const titles = vm.$el.querySelectorAll('.legend-metric-title');
const titles = component.$el.querySelectorAll('.legend-metric-title');
expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1); expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1); expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
expect(getTextFromNode(component, '.y-label-text')).toEqual(component.yAxisLabel);
}); });
it('should contain the same number of legend groups as the timeSeries length', () => { it('should container the same number of rows in the table as time series', () => {
const component = createComponent(defaultValuesComponent); expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.timeSeries.length);
});
expect(component.$el.querySelectorAll('.legend-group').length).toEqual(component.timeSeries.length);
}); });
}); });
import Vue from 'vue';
import TrackInfo from '~/monitoring/components/graph/track_info.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
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);
describe('TrackInfo component', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(TrackInfo);
});
afterEach(() => {
vm.$destroy();
});
describe('Computed props', () => {
beforeEach(() => {
vm = mountComponent(Component, { track: timeSeries[0] });
});
it('summaryMetrics', () => {
expect(vm.summaryMetrics).toEqual('Avg: 0.000 · Max: 0.000');
});
});
describe('Rendered output', () => {
beforeEach(() => {
vm = mountComponent(Component, { track: timeSeries[0] });
});
it('contains metric tag and the summary metrics', () => {
const metricTag = vm.$el.querySelector('strong');
expect(metricTag.textContent.trim()).toEqual(vm.track.metricTag);
expect(vm.$el.textContent).toContain('Avg: 0.000 · Max: 0.000');
});
});
});
import Vue from 'vue';
import TrackLine from '~/monitoring/components/graph/track_line.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
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);
describe('TrackLine component', () => {
let vm;
let Component;
beforeEach(() => {
Component = Vue.extend(TrackLine);
});
afterEach(() => {
vm.$destroy();
});
describe('Computed props', () => {
it('stylizedLine for dashed lineStyles', () => {
vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dashed' } });
expect(vm.stylizedLine).toEqual('6, 3');
});
it('stylizedLine for dotted lineStyles', () => {
vm = mountComponent(Component, { track: { ...timeSeries[0], lineStyle: 'dotted' } });
expect(vm.stylizedLine).toEqual('3, 3');
});
});
describe('Rendered output', () => {
it('has an svg with a line', () => {
vm = mountComponent(Component, { track: { ...timeSeries[0] } });
const svgEl = vm.$el.querySelector('svg');
const lineEl = vm.$el.querySelector('svg line');
expect(svgEl.getAttribute('width')).toEqual('15');
expect(svgEl.getAttribute('height')).toEqual('6');
expect(lineEl.getAttribute('stroke-width')).toEqual('4');
expect(lineEl.getAttribute('x1')).toEqual('0');
expect(lineEl.getAttribute('x2')).toEqual('15');
expect(lineEl.getAttribute('y1')).toEqual('2');
expect(lineEl.getAttribute('y2')).toEqual('2');
});
});
});
...@@ -2,11 +2,15 @@ import Vue from 'vue'; ...@@ -2,11 +2,15 @@ import Vue from 'vue';
import Graph from '~/monitoring/components/graph.vue'; import Graph from '~/monitoring/components/graph.vue';
import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins'; import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
import eventHub from '~/monitoring/event_hub'; import eventHub from '~/monitoring/event_hub';
import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from './mock_data'; import {
deploymentData,
convertDatesMultipleSeries,
singleRowMetricsMultipleSeries,
} from './mock_data';
const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags'; const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
const projectPath = 'http://test.host/frontend-fixtures/environments-project'; const projectPath = 'http://test.host/frontend-fixtures/environments-project';
const createComponent = (propsData) => { const createComponent = propsData => {
const Component = Vue.extend(Graph); const Component = Vue.extend(Graph);
return new Component({ return new Component({
...@@ -14,7 +18,9 @@ const createComponent = (propsData) => { ...@@ -14,7 +18,9 @@ const createComponent = (propsData) => {
}).$mount(); }).$mount();
}; };
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries); const convertedMetrics = convertDatesMultipleSeries(
singleRowMetricsMultipleSeries,
);
describe('Graph', () => { describe('Graph', () => {
beforeEach(() => { beforeEach(() => {
...@@ -31,7 +37,9 @@ describe('Graph', () => { ...@@ -31,7 +37,9 @@ describe('Graph', () => {
projectPath, projectPath,
}); });
expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(component.graphData.title); expect(component.$el.querySelector('.text-center').innerText.trim()).toBe(
component.graphData.title,
);
}); });
describe('Computed props', () => { describe('Computed props', () => {
...@@ -46,8 +54,9 @@ describe('Graph', () => { ...@@ -46,8 +54,9 @@ describe('Graph', () => {
}); });
const transformedHeight = `${component.graphHeight - 100}`; const transformedHeight = `${component.graphHeight - 100}`;
expect(component.axisTransform.indexOf(transformedHeight)) expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(
.not.toEqual(-1); -1,
);
}); });
it('outerViewBox gets a width and height property based on the DOM size of the element', () => { it('outerViewBox gets a width and height property based on the DOM size of the element', () => {
...@@ -63,11 +72,11 @@ describe('Graph', () => { ...@@ -63,11 +72,11 @@ describe('Graph', () => {
const viewBoxArray = component.outerViewBox.split(' '); const viewBoxArray = component.outerViewBox.split(' ');
expect(typeof component.outerViewBox).toEqual('string'); expect(typeof component.outerViewBox).toEqual('string');
expect(viewBoxArray[2]).toEqual(component.graphWidth.toString()); expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
expect(viewBoxArray[3]).toEqual(component.graphHeight.toString()); expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString());
}); });
}); });
it('sends an event to the eventhub when it has finished resizing', (done) => { it('sends an event to the eventhub when it has finished resizing', done => {
const component = createComponent({ const component = createComponent({
graphData: convertedMetrics[1], graphData: convertedMetrics[1],
classType: 'col-md-6', classType: 'col-md-6',
......
This diff is collapsed.
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