Commit d52b352b authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ee-adriel-remove-d3-metrics-graph' into 'master'

EE compatibility port: CE Remove d3 metrics graph

See merge request gitlab-org/gitlab-ee!9308
parents 04f6200d c24642fd
......@@ -9,15 +9,12 @@ import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
import eventHub from '../event_hub';
export default {
components: {
MonitorAreaChart,
Graph,
GraphGroup,
EmptyState,
Icon,
......@@ -32,21 +29,11 @@ export default {
required: false,
default: true,
},
showLegend: {
type: Boolean,
required: false,
default: true,
},
showPanels: {
type: Boolean,
required: false,
default: true,
},
forceSmallGraph: {
type: Boolean,
required: false,
default: false,
},
documentationPath: {
type: String,
required: true,
......@@ -111,14 +98,10 @@ export default {
store: new MonitoringStore(),
state: 'gettingStarted',
showEmptyState: true,
hoverData: {},
elWidth: 0,
};
},
computed: {
graphComponent() {
return gon.features && gon.features.areaChart ? MonitorAreaChart : Graph;
},
forceRedraw() {
return this.elWidth;
},
......@@ -134,10 +117,8 @@ export default {
childList: false,
subtree: false,
};
eventHub.$on('hoverChanged', this.hoverChanged);
},
beforeDestroy() {
eventHub.$off('hoverChanged', this.hoverChanged);
window.removeEventListener('resize', this.resizeThrottled, false);
this.sidebarMutationObserver.disconnect();
},
......@@ -198,9 +179,6 @@ export default {
resize() {
this.elWidth = this.$el.clientWidth;
},
hoverChanged(data) {
this.hoverData = data;
},
},
};
</script>
......@@ -237,30 +215,14 @@ export default {
:name="groupData.group"
:show-panels="showPanels"
>
<component
:is="graphComponent"
<monitor-area-chart
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
:hover-data="hoverData"
:deployment-data="store.deploymentData"
:project-path="projectPath"
:tags-path="tagsPath"
:show-legend="showLegend"
:small-graph="forceSmallGraph"
:alert-data="getGraphAlerts(graphData.id)"
group-id="monitor-area-chart"
>
<!-- 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"
......@@ -270,7 +232,7 @@ export default {
:alert-data="alertData[graphData.id]"
@setAlerts="setAlerts"
/>
</component>
</monitor-area-chart>
</graph-group>
</div>
<empty-state
......
This diff is collapsed.
<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
:y1="yPosition"
:x2="graphWidth + 20"
:y2="yPosition"
class="label-x-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
/>
<line
:x2="10"
:y2="yPosition"
class="label-y-axis-line"
stroke="#000000"
stroke-width="1"
x1="10"
y1="0"
/>
<rect
:transform="rectTransform"
:width="yLabelWidth"
:height="yLabelHeight"
class="rect-axis-text"
/>
<text
ref="ylabel"
:transform="textTransform"
class="label-axis-text y-label-text"
text-anchor="middle"
>
{{ yAxisLabelSentenceCase }}
</text>
<rect :x="xPosition + 60" :y="graphHeight - 80" class="rect-axis-text" width="35" height="50" />
<text :x="xPosition + 60" :y="yPosition" class="label-axis-text x-label-text" dy=".35em">
{{ timeString }}
</text>
</g>
</template>
<script>
export default {
props: {
deploymentData: {
type: Array,
required: true,
},
graphHeight: {
type: Number,
required: true,
},
graphHeightOffset: {
type: Number,
required: true,
},
},
computed: {
calculatedHeight() {
return this.graphHeight - this.graphHeightOffset;
},
},
methods: {
transformDeploymentGroup(deployment) {
return `translate(${Math.floor(deployment.xPos) - 5}, 20)`;
},
},
};
</script>
<template>
<g class="deploy-info">
<g
v-for="(deployment, index) in deploymentData"
:key="index"
:transform="transformDeploymentGroup(deployment)"
>
<rect :height="calculatedHeight" x="0" y="0" width="3" fill="url(#shadow-gradient)" />
<line :y2="calculatedHeight" class="deployment-line" x1="0" y1="0" x2="0" stroke="#000" />
</g>
<svg height="0" width="0">
<defs>
<linearGradient id="shadow-gradient">
<stop offset="0%" stop-color="#000" stop-opacity="0.4" />
<stop offset="100%" stop-color="#000" stop-opacity="0" />
</linearGradient>
</defs>
</svg>
</g>
</template>
<script>
import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
import { formatRelevantDigits } from '../../../lib/utils/number_utils';
import Icon from '../../../vue_shared/components/icon.vue';
import TrackLine from './track_line.vue';
export default {
components: {
Icon,
TrackLine,
},
props: {
currentXCoordinate: {
type: Number,
required: true,
},
currentData: {
type: Object,
required: true,
},
deploymentFlagData: {
type: Object,
required: false,
default: null,
},
graphHeight: {
type: Number,
required: true,
},
graphHeightOffset: {
type: Number,
required: true,
},
realPixelRatio: {
type: Number,
required: true,
},
showFlagContent: {
type: Boolean,
required: true,
},
timeSeries: {
type: Array,
required: true,
},
unitOfDisplay: {
type: String,
required: true,
},
legendTitle: {
type: String,
required: true,
},
currentCoordinates: {
type: Object,
required: true,
},
},
computed: {
formatTime() {
return this.deploymentFlagData
? timeFormat(this.deploymentFlagData.time)
: timeFormat(this.currentData.time);
},
formatDate() {
return this.deploymentFlagData
? dateFormat(this.deploymentFlagData.time)
: dateFormat(this.currentData.time);
},
cursorStyle() {
const xCoordinate = this.deploymentFlagData
? this.deploymentFlagData.xPos
: this.currentXCoordinate;
const offsetTop = 20 * this.realPixelRatio;
const offsetLeft = (70 + xCoordinate) * this.realPixelRatio;
const height = (this.graphHeight - this.graphHeightOffset) * this.realPixelRatio;
return {
top: `${offsetTop}px`,
left: `${offsetLeft}px`,
height: `${height}px`,
};
},
flagOrientation() {
if (this.currentXCoordinate * this.realPixelRatio > 120) {
return 'left';
}
return 'right';
},
},
methods: {
seriesMetricValue(seriesIndex, series) {
const indexFromCoordinates = this.currentCoordinates[series.metricTag]
? this.currentCoordinates[series.metricTag].currentDataIndex
: 0;
const index = this.deploymentFlagData
? this.deploymentFlagData.seriesIndex
: indexFromCoordinates;
const value = series.values[index] && series.values[index].value;
if (Number.isNaN(value)) {
return '-';
}
return `${formatRelevantDigits(value)}${this.unitOfDisplay}`;
},
seriesMetricLabel(index, series) {
if (this.timeSeries.length < 2) {
return this.legendTitle;
}
if (series.metricTag) {
return series.metricTag;
}
return `series ${index + 1}`;
},
},
};
</script>
<template>
<div :style="cursorStyle" class="prometheus-graph-cursor">
<div v-if="showFlagContent" :class="flagOrientation" class="prometheus-graph-flag popover">
<div class="arrow-shadow"></div>
<div class="arrow"></div>
<div class="popover-header">
<h5 v-if="deploymentFlagData">Deployed</h5>
{{ formatDate }} <strong>{{ formatTime }}</strong>
</div>
<div v-if="deploymentFlagData" class="popover-body deploy-meta-content">
<div>
<icon :size="12" name="commit" />
<a :href="deploymentFlagData.commitUrl"> {{ deploymentFlagData.sha.slice(0, 8) }} </a>
</div>
<div v-if="deploymentFlagData.tag">
<icon :size="12" name="label" />
<a :href="deploymentFlagData.tagUrl"> {{ deploymentFlagData.ref }} </a>
</div>
</div>
<div class="popover-body">
<table class="prometheus-table">
<tr v-for="(series, index) in timeSeries" :key="index">
<track-line :track="series" />
<td>{{ series.track }} {{ seriesMetricLabel(index, series) }}</td>
<td>
<strong>{{ seriesMetricValue(index, series) }}</strong>
</td>
</tr>
</table>
</div>
</div>
</div>
</template>
<script>
import TrackLine from './track_line.vue';
import TrackInfo from './track_info.vue';
export default {
components: {
TrackLine,
TrackInfo,
},
props: {
legendTitle: {
type: String,
required: true,
},
timeSeries: {
type: Array,
required: true,
},
},
methods: {
isStable(track) {
return {
'prometheus-table-row-highlight': track.trackName !== 'Canary' && track.renderCanary,
};
},
},
};
</script>
<template>
<div class="prometheus-graph-legends prepend-left-10">
<table class="prometheus-table">
<tr
v-for="(series, index) in timeSeries"
v-if="series.shouldRenderLegend"
:key="index"
:class="isStable(series)"
>
<td>
<strong v-if="series.renderCanary">{{ series.trackName }}</strong>
</td>
<track-line :track="series" />
<td v-if="timeSeries.length > 1" class="legend-metric-title">
<track-info v-if="series.metricTag" :track="series" />
<track-info 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 :key="`track-line-${trackIndex}`" :track="track" />
<td :key="`track-info-${trackIndex}`">
<track-info :track="track" class="legend-metric-title" />
</td>
</template>
</tr>
</table>
</div>
</template>
<script>
export default {
props: {
generatedLinePath: {
type: String,
required: true,
},
generatedAreaPath: {
type: String,
required: true,
},
lineStyle: {
type: String,
required: false,
default: '',
},
lineColor: {
type: String,
required: true,
},
areaColor: {
type: String,
required: true,
},
currentCoordinates: {
type: Object,
required: false,
default: () => ({ currentX: 0, currentY: 0 }),
},
showDot: {
type: Boolean,
required: true,
},
},
computed: {
strokeDashArray() {
if (this.lineStyle === 'dashed') return '3, 1';
if (this.lineStyle === 'dotted') return '1, 1';
return null;
},
},
};
</script>
<template>
<g transform="translate(-5, 20)">
<circle
v-if="showDot"
:cx="currentCoordinates.currentX"
:cy="currentCoordinates.currentY"
:fill="lineColor"
:stroke="lineColor"
class="circle-path"
r="3"
/>
<path :d="generatedAreaPath" :fill="areaColor" class="metric-area" />
<path
:d="generatedLinePath"
:stroke="lineColor"
:stroke-dasharray="strokeDashArray"
class="metric-line"
fill="none"
stroke-width="1"
/>
</g>
</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="16" height="8">
<line
:stroke-dasharray="stylizedLine"
:stroke="track.lineColor"
:x1="0"
:x2="16"
:y1="4"
:y2="4"
stroke-width="4"
/>
</svg>
</td>
</template>
import Vue from 'vue';
export default new Vue();
import { bisectDate } from '../utils/date_time_formatters';
const mixins = {
methods: {
mouseOverDeployInfo(mouseXPos) {
if (!this.reducedDeploymentData) return false;
let dataFound = false;
this.reducedDeploymentData = this.reducedDeploymentData.map(d => {
const deployment = d;
if (d.xPos >= mouseXPos - 10 && d.xPos <= mouseXPos + 10 && !dataFound) {
dataFound = d.xPos + 1;
deployment.showDeploymentFlag = true;
} else {
deployment.showDeploymentFlag = false;
}
return deployment;
});
return dataFound;
},
formatDeployments() {
this.reducedDeploymentData = this.deploymentData.reduce((deploymentDataArray, deployment) => {
const time = new Date(deployment.created_at);
const xPos = Math.floor(this.timeSeries[0].timeSeriesScaleX(time));
time.setSeconds(this.timeSeries[0].values[0].time.getSeconds());
if (xPos >= 0) {
const seriesIndex = bisectDate(this.timeSeries[0].values, time, 1);
deploymentDataArray.push({
id: deployment.id,
time,
sha: deployment.sha,
commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
tag: deployment.tag,
tagUrl: deployment.tag ? `${this.tagsPath}/${deployment.ref.name}` : null,
ref: deployment.ref.name,
xPos,
seriesIndex,
showDeploymentFlag: false,
});
}
return deploymentDataArray;
}, []);
},
positionFlag() {
const timeSeries = this.seriesUnderMouse[0];
if (!timeSeries) {
return;
}
const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate);
this.currentData = timeSeries.values[hoveredDataIndex];
this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time));
this.currentCoordinates = {};
this.seriesUnderMouse.forEach(series => {
const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate);
const currentData = series.values[currentDataIndex];
const currentX = Math.floor(series.timeSeriesScaleX(currentData.time));
const currentY = Math.floor(series.timeSeriesScaleY(currentData.value));
this.currentCoordinates[series.metricTag] = {
currentX,
currentY,
currentDataIndex,
};
});
if (this.hoverData.currentDeployXPos) {
this.showFlag = false;
} else {
this.showFlag = true;
}
},
},
};
export default mixins;
import { timeFormat as time } from 'd3-time-format';
import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from 'd3-time';
import { bisector } from 'd3-array';
const d3 = {
time,
bisector,
timeSecond,
timeMinute,
timeHour,
timeDay,
timeWeek,
timeMonth,
timeYear,
};
export const dateFormat = d3.time('%d %b %Y, ');
export const timeFormat = d3.time('%-I:%M%p');
export const dateFormatWithName = d3.time('%a, %b %-d');
export const bisectDate = d3.bisector(d => d.time).left;
export function timeScaleFormat(date) {
let formatFunction;
if (d3.timeSecond(date) < date) {
formatFunction = d3.time('.%L');
} else if (d3.timeMinute(date) < date) {
formatFunction = d3.time(':%S');
} else if (d3.timeHour(date) < date) {
formatFunction = d3.time('%-I:%M');
} else if (d3.timeDay(date) < date) {
formatFunction = d3.time('%-I %p');
} else if (d3.timeWeek(date) < date) {
formatFunction = d3.time('%a %d');
} else if (d3.timeMonth(date) < date) {
formatFunction = d3.time('%b %d');
} else if (d3.timeYear(date) < date) {
formatFunction = d3.time('%B');
} else {
formatFunction = d3.time('%Y');
}
return formatFunction(date);
}
export default {
small: {
// Covers both xs and sm screen sizes
margin: {
top: 40,
right: 40,
bottom: 50,
left: 40,
},
legends: {
width: 15,
height: 3,
offsetX: 20,
offsetY: 32,
},
backgroundLegend: {
width: 30,
height: 50,
},
axisLabelLineOffset: -20,
},
large: {
// This covers both md and lg screen sizes
margin: {
top: 80,
right: 80,
bottom: 100,
left: 80,
},
legends: {
width: 15,
height: 3,
offsetX: 20,
offsetY: 34,
},
backgroundLegend: {
width: 30,
height: 150,
},
axisLabelLineOffset: 20,
},
xTicks: 8,
yTicks: 3,
};
import _ from 'underscore';
import { scaleLinear, scaleTime } from 'd3-scale';
import { line, area, curveLinear } from 'd3-shape';
import { extent, max, sum } from 'd3-array';
import { timeMinute, timeSecond } from 'd3-time';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
const d3 = {
scaleLinear,
scaleTime,
line,
area,
curveLinear,
extent,
max,
timeMinute,
timeSecond,
sum,
};
const defaultColorPalette = {
blue: ['#1f78d1', '#8fbce8'],
orange: ['#fc9403', '#feca81'],
red: ['#db3b21', '#ed9d90'],
green: ['#1aaa55', '#8dd5aa'],
purple: ['#6666c4', '#d1d1f0'],
};
const defaultColorOrder = ['blue', 'orange', 'red', 'green', 'purple'];
const defaultStyleOrder = ['solid', 'dashed', 'dotted'];
function queryTimeSeries(query, graphDrawData, lineStyle) {
let usedColors = [];
let renderCanary = false;
const timeSeriesParsed = [];
function pickColor(name) {
let pick;
if (name && defaultColorPalette[name]) {
pick = name;
} else {
const unusedColors = _.difference(defaultColorOrder, usedColors);
if (unusedColors.length > 0) {
[pick] = unusedColors;
} else {
usedColors = [];
[pick] = defaultColorOrder;
}
}
usedColors.push(pick);
return defaultColorPalette[pick];
}
function findByDate(series, time) {
const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60);
if (val) {
return val.value;
}
return NaN;
}
// The timeseries data may have gaps in it
// 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] = graphDrawData.xDom;
const offset = d3.timeMinute(minX) - Number(minX);
const datesWithoutGaps = d3.timeSecond
.every(60)
.range(d3.timeMinute.offset(minX, -1), maxX)
.map(d => d - offset);
query.result.forEach((timeSeries, timeSeriesNumber) => {
let metricTag = '';
let lineColor = '';
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 timeSeriesMetricLabel = timeSeries.metric[Object.keys(timeSeries.metric)[0]];
const seriesCustomizationData =
query.series != null && _.findWhere(query.series[0].when, { value: timeSeriesMetricLabel });
if (seriesCustomizationData) {
metricTag = seriesCustomizationData.value || timeSeriesMetricLabel;
[lineColor, areaColor] = pickColor(seriesCustomizationData.color);
if (timeSeriesParsed.length > 0) {
shouldRenderLegend = false;
} else {
shouldRenderLegend = true;
}
} else {
metricTag = timeSeriesMetricLabel || query.label || `series ${timeSeriesNumber + 1}`;
[lineColor, areaColor] = pickColor();
if (timeSeriesParsed.length > 1) {
shouldRenderLegend = false;
}
}
const values = datesWithoutGaps.map(time => ({
time,
value: findByDate(timeSeries.values, time),
}));
timeSeriesParsed.push({
linePath: graphDrawData.lineFunction(values),
areaPath: graphDrawData.areaBelowLine(values),
timeSeriesScaleX: graphDrawData.timeSeriesScaleX,
timeSeriesScaleY: graphDrawData.timeSeriesScaleY,
values: timeSeries.values,
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle,
lineColor,
areaColor,
metricTag,
trackName,
shouldRenderLegend,
renderCanary,
});
if (!shouldRenderLegend) {
if (!timeSeriesParsed[0].tracksLegend) {
timeSeriesParsed[0].tracksLegend = [];
}
timeSeriesParsed[0].tracksLegend.push({
max: maximumValue,
average: accum / timeSeries.values.length,
lineStyle,
lineColor,
metricTag,
});
}
});
return timeSeriesParsed;
}
function xyDomain(queries) {
const allValues = queries.reduce(
(allQueryResults, query) =>
allQueryResults.concat(
query.result.reduce((allResults, result) => allResults.concat(result.values), []),
),
[],
);
const xDom = d3.extent(allValues, d => d.time);
const yDom = [0, d3.max(allValues.map(d => d.value))];
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, graphDrawData, lineStyle));
}, []);
return {
timeSeries,
graphDrawData,
};
}
import AlertWidget from './alert_widget.vue';
import ThresholdLines from './threshold_lines.vue';
export default {
components: {
AlertWidget,
ThresholdLines,
},
props: {
alertsEndpoint: {
......
<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>
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();
});
});
});
......@@ -5821,9 +5821,6 @@ msgstr ""
msgid "Metrics|New metric"
msgstr ""
msgid "Metrics|No data to display"
msgstr ""
msgid "Metrics|No deployed environments"
msgstr ""
......@@ -7337,9 +7334,6 @@ msgstr ""
msgid "PrometheusAlerts|Threshold"
msgstr ""
msgid "PrometheusDashboard|Time"
msgstr ""
msgid "PrometheusService|%{exporters} with %{metrics} were found"
msgstr ""
......
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 GraphDeployment from '~/monitoring/components/graph/deployment.vue';
import { deploymentData } from '../mock_data';
const createComponent = propsData => {
const Component = Vue.extend(GraphDeployment);
return new Component({
propsData,
}).$mount();
};
describe('MonitoringDeployment', () => {
describe('Methods', () => {
it('should contain a hidden gradient', () => {
const component = createComponent({
showDeployInfo: true,
deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
});
expect(component.$el.querySelector('#shadow-gradient')).not.toBeNull();
});
it('transformDeploymentGroup translates an available deployment', () => {
const component = createComponent({
showDeployInfo: false,
deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
});
expect(component.transformDeploymentGroup({ xPos: 16 })).toContain('translate(11, 20)');
});
describe('Computed props', () => {
it('calculatedHeight', () => {
const component = createComponent({
showDeployInfo: true,
deploymentData,
graphHeight: 300,
graphWidth: 440,
graphHeightOffset: 120,
});
expect(component.calculatedHeight).toEqual(180);
});
});
});
});
import Vue from 'vue';
import GraphFlag from '~/monitoring/components/graph/flag.vue';
import { deploymentData } from '../mock_data';
const createComponent = propsData => {
const Component = Vue.extend(GraphFlag);
return new Component({
propsData,
}).$mount();
};
const defaultValuesComponent = {
currentXCoordinate: 200,
currentYCoordinate: 100,
currentFlagPosition: 100,
currentData: {
time: new Date('2017-06-04T18:17:33.501Z'),
value: '1.49609375',
},
graphHeight: 300,
graphHeightOffset: 120,
showFlagContent: true,
realPixelRatio: 1,
timeSeries: [
{
values: [
{
time: new Date('2017-06-04T18:17:33.501Z'),
value: '1.49609375',
},
],
},
],
unitOfDisplay: 'ms',
currentDataIndex: 0,
legendTitle: 'Average',
currentCoordinates: {},
};
const deploymentFlagData = {
...deploymentData[0],
ref: deploymentData[0].ref.name,
xPos: 10,
time: new Date(deploymentData[0].created_at),
};
describe('GraphFlag', () => {
let component;
it('has a line at the currentXCoordinate', () => {
component = createComponent(defaultValuesComponent);
expect(component.$el.style.left).toEqual(`${70 + component.currentXCoordinate}px`);
});
describe('Deployment flag', () => {
it('shows a deployment flag when deployment data provided', () => {
const deploymentFlagComponent = createComponent({
...defaultValuesComponent,
deploymentFlagData,
});
expect(deploymentFlagComponent.$el.querySelector('.popover-header')).toContainText(
'Deployed',
);
});
it('contains the ref when a tag is available', () => {
const deploymentFlagComponent = createComponent({
...defaultValuesComponent,
deploymentFlagData: {
...deploymentFlagData,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
tag: true,
ref: '1.0',
},
});
expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
'f5bcd1d9',
);
expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
'1.0',
);
});
it('does not contain the ref when a tag is unavailable', () => {
const deploymentFlagComponent = createComponent({
...defaultValuesComponent,
deploymentFlagData: {
...deploymentFlagData,
sha: 'f5bcd1d9dac6fa4137e2510b9ccd134ef2e84187',
tag: false,
ref: '1.0',
},
});
expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).toContainText(
'f5bcd1d9',
);
expect(deploymentFlagComponent.$el.querySelector('.deploy-meta-content')).not.toContainText(
'1.0',
);
});
});
describe('Computed props', () => {
beforeEach(() => {
component = createComponent(defaultValuesComponent);
});
it('formatTime', () => {
expect(component.formatTime).toMatch(/\d:17PM/);
});
it('formatDate', () => {
expect(component.formatDate).toEqual('04 Jun 2017, ');
});
it('cursorStyle', () => {
expect(component.cursorStyle).toEqual({
top: '20px',
left: '270px',
height: '180px',
});
});
it('flagOrientation', () => {
expect(component.flagOrientation).toEqual('left');
});
});
});
import Vue from 'vue';
import GraphLegend from '~/monitoring/components/graph/legend.vue';
import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from '../mock_data';
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const defaultValuesComponent = {};
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 500, 300, 120);
defaultValuesComponent.timeSeries = timeSeries;
describe('Legend Component', () => {
let vm;
let Legend;
beforeEach(() => {
Legend = Vue.extend(GraphLegend);
});
describe('View', () => {
beforeEach(() => {
vm = mountComponent(Legend, {
legendTitle: 'legend',
timeSeries,
currentDataIndex: 0,
unitOfDisplay: 'Req/Sec',
});
});
it('should render the usage, title and time with multiple time series', () => {
const titles = vm.$el.querySelectorAll('.legend-metric-title');
expect(titles[0].textContent.indexOf('1xx')).not.toEqual(-1);
expect(titles[1].textContent.indexOf('2xx')).not.toEqual(-1);
});
it('should container the same number of rows in the table as time series', () => {
expect(vm.$el.querySelectorAll('.prometheus-table tr').length).toEqual(vm.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('16');
expect(svgEl.getAttribute('height')).toEqual('8');
expect(lineEl.getAttribute('stroke-width')).toEqual('4');
expect(lineEl.getAttribute('x1')).toEqual('0');
expect(lineEl.getAttribute('x2')).toEqual('16');
expect(lineEl.getAttribute('y1')).toEqual('4');
expect(lineEl.getAttribute('y2')).toEqual('4');
});
});
});
import Vue from 'vue';
import GraphPath from '~/monitoring/components/graph/path.vue';
import createTimeSeries from '~/monitoring/utils/multiple_time_series';
import { singleRowMetricsMultipleSeries, convertDatesMultipleSeries } from './mock_data';
const createComponent = propsData => {
const Component = Vue.extend(GraphPath);
return new Component({
propsData,
}).$mount();
};
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
const { timeSeries } = createTimeSeries(convertedMetrics[0].queries, 428, 272, 120);
const firstTimeSeries = timeSeries[0];
describe('Monitoring Paths', () => {
it('renders two paths to represent a line and the area underneath it', () => {
const component = createComponent({
generatedLinePath: firstTimeSeries.linePath,
generatedAreaPath: firstTimeSeries.areaPath,
lineColor: firstTimeSeries.lineColor,
areaColor: firstTimeSeries.areaColor,
showDot: false,
});
const metricArea = component.$el.querySelector('.metric-area');
const metricLine = component.$el.querySelector('.metric-line');
expect(metricArea.getAttribute('fill')).toBe('#8fbce8');
expect(metricArea.getAttribute('d')).toBe(firstTimeSeries.areaPath);
expect(metricLine.getAttribute('stroke')).toBe('#1f78d1');
expect(metricLine.getAttribute('d')).toBe(firstTimeSeries.linePath);
});
describe('Computed properties', () => {
it('strokeDashArray', () => {
const component = createComponent({
generatedLinePath: firstTimeSeries.linePath,
generatedAreaPath: firstTimeSeries.areaPath,
lineColor: firstTimeSeries.lineColor,
areaColor: firstTimeSeries.areaColor,
showDot: false,
});
component.lineStyle = 'dashed';
expect(component.strokeDashArray).toBe('3, 1');
component.lineStyle = 'dotted';
expect(component.strokeDashArray).toBe('1, 1');
});
});
});
import Vue from 'vue';
import Graph from '~/monitoring/components/graph.vue';
import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
import {
deploymentData,
convertDatesMultipleSeries,
singleRowMetricsMultipleSeries,
queryWithoutData,
} from './mock_data';
const tagsPath = 'http://test.host/frontend-fixtures/environments-project/tags';
const projectPath = 'http://test.host/frontend-fixtures/environments-project';
const createComponent = propsData => {
const Component = Vue.extend(Graph);
return new Component({
propsData,
}).$mount();
};
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
describe('Graph', () => {
beforeEach(() => {
spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({});
});
it('has a title', () => {
const component = createComponent({
graphData: convertedMetrics[1],
updateAspectRatio: false,
deploymentData,
tagsPath,
projectPath,
});
expect(component.$el.querySelector('.prometheus-graph-title').innerText.trim()).toBe(
component.graphData.title,
);
});
describe('Computed props', () => {
it('axisTransform translates an element Y position depending of its height', () => {
const component = createComponent({
graphData: convertedMetrics[1],
updateAspectRatio: false,
deploymentData,
tagsPath,
projectPath,
});
const transformedHeight = `${component.graphHeight - 100}`;
expect(component.axisTransform.indexOf(transformedHeight)).not.toEqual(-1);
});
it('outerViewBox gets a width and height property based on the DOM size of the element', () => {
const component = createComponent({
graphData: convertedMetrics[1],
updateAspectRatio: false,
deploymentData,
tagsPath,
projectPath,
});
const viewBoxArray = component.outerViewBox.split(' ');
expect(typeof component.outerViewBox).toEqual('string');
expect(viewBoxArray[2]).toEqual(component.graphWidth.toString());
expect(viewBoxArray[3]).toEqual((component.graphHeight - 50).toString());
});
});
it('has a title for the y-axis and the chart legend that comes from the backend', () => {
const component = createComponent({
graphData: convertedMetrics[1],
updateAspectRatio: false,
deploymentData,
tagsPath,
projectPath,
});
expect(component.yAxisLabel).toEqual(component.graphData.y_label);
expect(component.legendTitle).toEqual(component.graphData.queries[0].label);
});
it('sets the currentData object based on the hovered data index', () => {
const component = createComponent({
graphData: convertedMetrics[1],
updateAspectRatio: false,
deploymentData,
graphIdentifier: 0,
hoverData: {
hoveredDate: new Date('Sun Aug 27 2017 06:11:51 GMT-0500 (CDT)'),
currentDeployXPos: null,
},
tagsPath,
projectPath,
});
// simulate moving mouse over data series
component.seriesUnderMouse = component.timeSeries;
component.positionFlag();
expect(component.currentData).toBe(component.timeSeries[0].values[10]);
});
describe('Without data to display', () => {
it('shows a "no data to display" empty state on a graph', done => {
const component = createComponent({
graphData: queryWithoutData,
deploymentData,
tagsPath,
projectPath,
});
Vue.nextTick(() => {
expect(
component.$el.querySelector('.js-no-data-to-display text').textContent.trim(),
).toEqual('No data to display');
done();
});
});
});
});
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 firstTimeSeries = timeSeries[0];
describe('Multiple time series', () => {
it('createTimeSeries returned array contains an object for each element', () => {
expect(typeof firstTimeSeries.linePath).toEqual('string');
expect(typeof firstTimeSeries.areaPath).toEqual('string');
expect(typeof firstTimeSeries.timeSeriesScaleX).toEqual('function');
expect(typeof firstTimeSeries.areaColor).toEqual('string');
expect(typeof firstTimeSeries.lineColor).toEqual('string');
expect(firstTimeSeries.values instanceof Array).toEqual(true);
});
it('createTimeSeries returns an array', () => {
expect(timeSeries instanceof Array).toEqual(true);
expect(timeSeries.length).toEqual(2);
});
});
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