summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/monitoring
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2019-10-30 15:14:17 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2019-10-30 15:14:17 +0000
commit3fe9588b1c1c4fb58f8ba8e9c27244fc2fc1c103 (patch)
treed19448d010ff9d58fed14846736ee358fb6b3327 /app/assets/javascripts/monitoring
parentad8eea383406037a207c80421e6e4bfa357f8044 (diff)
downloadgitlab-ce-3fe9588b1c1c4fb58f8ba8e9c27244fc2fc1c103.tar.gz
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/monitoring')
-rw-r--r--app/assets/javascripts/monitoring/components/charts/anomaly.vue227
-rw-r--r--app/assets/javascripts/monitoring/components/charts/time_series.vue75
-rw-r--r--app/assets/javascripts/monitoring/components/panel_type.vue15
-rw-r--r--app/assets/javascripts/monitoring/constants.js15
-rw-r--r--app/assets/javascripts/monitoring/utils.js16
5 files changed, 325 insertions, 23 deletions
diff --git a/app/assets/javascripts/monitoring/components/charts/anomaly.vue b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
new file mode 100644
index 00000000000..8eeac737a11
--- /dev/null
+++ b/app/assets/javascripts/monitoring/components/charts/anomaly.vue
@@ -0,0 +1,227 @@
+<script>
+import { flatten, isNumber } from 'underscore';
+import { GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
+import { roundOffFloat } from '~/lib/utils/common_utils';
+import { hexToRgb } from '~/lib/utils/color_utils';
+import { areaOpacityValues, symbolSizes, colorValues } from '../../constants';
+import { graphDataValidatorForAnomalyValues } from '../../utils';
+import MonitorTimeSeriesChart from './time_series.vue';
+
+/**
+ * Series indexes
+ */
+const METRIC = 0;
+const UPPER = 1;
+const LOWER = 2;
+
+/**
+ * Boundary area appearance
+ */
+const AREA_COLOR = colorValues.anomalyAreaColor;
+const AREA_OPACITY = areaOpacityValues.default;
+const AREA_COLOR_RGBA = `rgba(${hexToRgb(AREA_COLOR).join(',')},${AREA_OPACITY})`;
+
+/**
+ * The anomaly component highlights when a metric shows
+ * some anomalous behavior.
+ *
+ * It shows both a metric line and a boundary band in a
+ * time series chart, the boundary band shows the normal
+ * range of values the metric should take.
+ *
+ * This component accepts 3 queries, which contain the
+ * "metric", "upper" limit and "lower" limit.
+ *
+ * The upper and lower series are "stacked areas" visually
+ * to create the boundary band, and if any "metric" value
+ * is outside this band, it is highlighted to warn users.
+ *
+ * The boundary band stack must be painted above the 0 line
+ * so the area is shown correctly. If any of the values of
+ * the data are negative, the chart data is shifted to be
+ * above 0 line.
+ *
+ * The data passed to the time series is will always be
+ * positive, but reformatted to show the original values of
+ * data.
+ *
+ */
+export default {
+ components: {
+ GlLineChart,
+ GlChartSeriesLabel,
+ MonitorTimeSeriesChart,
+ },
+ inheritAttrs: false,
+ props: {
+ graphData: {
+ type: Object,
+ required: true,
+ validator: graphDataValidatorForAnomalyValues,
+ },
+ },
+ computed: {
+ series() {
+ return this.graphData.queries.map(query => {
+ const values = query.result[0] ? query.result[0].values : [];
+ return {
+ label: query.label,
+ data: values.filter(([, value]) => !Number.isNaN(value)),
+ };
+ });
+ },
+ /**
+ * If any of the values of the data is negative, the
+ * chart data is shifted to the lowest value
+ *
+ * This offset is the lowest value.
+ */
+ yOffset() {
+ const values = flatten(this.series.map(ser => ser.data.map(([, y]) => y)));
+ const min = values.length ? Math.floor(Math.min(...values)) : 0;
+ return min < 0 ? -min : 0;
+ },
+ metricData() {
+ const originalMetricQuery = this.graphData.queries[0];
+
+ const metricQuery = { ...originalMetricQuery };
+ metricQuery.result[0].values = metricQuery.result[0].values.map(([x, y]) => [
+ x,
+ y + this.yOffset,
+ ]);
+ return {
+ ...this.graphData,
+ type: 'line-chart',
+ queries: [metricQuery],
+ };
+ },
+ metricSeriesConfig() {
+ return {
+ type: 'line',
+ symbol: 'circle',
+ symbolSize: (val, params) => {
+ if (this.isDatapointAnomaly(params.dataIndex)) {
+ return symbolSizes.anomaly;
+ }
+ // 0 causes echarts to throw an error, use small number instead
+ // see https://gitlab.com/gitlab-org/gitlab-ui/issues/423
+ return 0.001;
+ },
+ showSymbol: true,
+ itemStyle: {
+ color: params => {
+ if (this.isDatapointAnomaly(params.dataIndex)) {
+ return colorValues.anomalySymbol;
+ }
+ return colorValues.primaryColor;
+ },
+ },
+ };
+ },
+ chartOptions() {
+ const [, upperSeries, lowerSeries] = this.series;
+ const calcOffsetY = (data, offsetCallback) =>
+ data.map((value, dataIndex) => {
+ const [x, y] = value;
+ return [x, y + offsetCallback(dataIndex)];
+ });
+
+ const yAxisWithOffset = {
+ name: this.yAxisLabel,
+ axisLabel: {
+ formatter: num => roundOffFloat(num - this.yOffset, 3).toString(),
+ },
+ };
+
+ /**
+ * Boundary is rendered by 2 series: An invisible
+ * series (opacity: 0) stacked on a visible one.
+ *
+ * Order is important, lower boundary is stacked
+ * *below* the upper boundary.
+ */
+ const boundarySeries = [];
+
+ if (upperSeries.data.length && lowerSeries.data.length) {
+ // Lower boundary, plus the offset if negative values
+ boundarySeries.push(
+ this.makeBoundarySeries({
+ name: this.formatLegendLabel(lowerSeries),
+ data: calcOffsetY(lowerSeries.data, () => this.yOffset),
+ }),
+ );
+ // Upper boundary, minus the lower boundary
+ boundarySeries.push(
+ this.makeBoundarySeries({
+ name: this.formatLegendLabel(upperSeries),
+ data: calcOffsetY(upperSeries.data, i => -this.yValue(LOWER, i)),
+ areaStyle: {
+ color: AREA_COLOR,
+ opacity: AREA_OPACITY,
+ },
+ }),
+ );
+ }
+ return { yAxis: yAxisWithOffset, series: boundarySeries };
+ },
+ },
+ methods: {
+ formatLegendLabel(query) {
+ return query.label;
+ },
+ yValue(seriesIndex, dataIndex) {
+ const d = this.series[seriesIndex].data[dataIndex];
+ return d && d[1];
+ },
+ yValueFormatted(seriesIndex, dataIndex) {
+ const y = this.yValue(seriesIndex, dataIndex);
+ return isNumber(y) ? y.toFixed(3) : '';
+ },
+ isDatapointAnomaly(dataIndex) {
+ const yVal = this.yValue(METRIC, dataIndex);
+ const yUpper = this.yValue(UPPER, dataIndex);
+ const yLower = this.yValue(LOWER, dataIndex);
+ return (isNumber(yUpper) && yVal > yUpper) || (isNumber(yLower) && yVal < yLower);
+ },
+ makeBoundarySeries(series) {
+ const stackKey = 'anomaly-boundary-series-stack';
+ return {
+ type: 'line',
+ stack: stackKey,
+ lineStyle: {
+ width: 0,
+ color: AREA_COLOR_RGBA, // legend color
+ },
+ color: AREA_COLOR_RGBA, // tooltip color
+ symbol: 'none',
+ ...series,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <monitor-time-series-chart
+ v-bind="$attrs"
+ :graph-data="metricData"
+ :option="chartOptions"
+ :series-config="metricSeriesConfig"
+ >
+ <slot></slot>
+ <template v-slot:tooltipContent="slotProps">
+ <div
+ v-for="(content, seriesIndex) in slotProps.tooltip.content"
+ :key="seriesIndex"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="content.color">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ yValueFormatted(seriesIndex, content.dataIndex) }}
+ </div>
+ </div>
+ </template>
+ </monitor-time-series-chart>
+</template>
diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue
index 434debb67f5..6a88c8a5ee3 100644
--- a/app/assets/javascripts/monitoring/components/charts/time_series.vue
+++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue
@@ -1,12 +1,20 @@
<script>
import { s__, __ } from '~/locale';
+import _ from 'underscore';
import { GlLink, GlButton, GlTooltip, GlResizeObserverDirective } from '@gitlab/ui';
import { GlAreaChart, GlLineChart, GlChartSeriesLabel } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import { roundOffFloat } from '~/lib/utils/common_utils';
import { getSvgIconPathContent } from '~/lib/utils/icon_utils';
import Icon from '~/vue_shared/components/icon.vue';
-import { chartHeight, graphTypes, lineTypes, symbolSizes, dateFormats } from '../../constants';
+import {
+ chartHeight,
+ graphTypes,
+ lineTypes,
+ lineWidths,
+ symbolSizes,
+ dateFormats,
+} from '../../constants';
import { makeDataSeries } from '~/helpers/monitor_helper';
import { graphDataValidatorForValues } from '../../utils';
@@ -30,6 +38,16 @@ export default {
required: true,
validator: graphDataValidatorForValues.bind(null, false),
},
+ option: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ seriesConfig: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
deploymentData: {
type: Array,
required: false,
@@ -96,29 +114,35 @@ export default {
const lineWidth =
appearance && appearance.line && appearance.line.width
? appearance.line.width
- : undefined;
+ : lineWidths.default;
const areaStyle = {
opacity:
appearance && appearance.area && typeof appearance.area.opacity === 'number'
? appearance.area.opacity
: undefined,
};
-
const series = makeDataSeries(query.result, {
name: this.formatLegendLabel(query),
lineStyle: {
type: lineType,
width: lineWidth,
+ color: this.primaryColor,
},
showSymbol: false,
areaStyle: this.graphData.type === 'area-chart' ? areaStyle : undefined,
+ ...this.seriesConfig,
});
return acc.concat(series);
}, []);
},
+ chartOptionSeries() {
+ return (this.option.series || []).concat(this.scatterSeries ? [this.scatterSeries] : []);
+ },
chartOptions() {
+ const option = _.omit(this.option, 'series');
return {
+ series: this.chartOptionSeries,
xAxis: {
name: __('Time'),
type: 'time',
@@ -135,8 +159,8 @@ export default {
formatter: num => roundOffFloat(num, 3).toString(),
},
},
- series: this.scatterSeries,
dataZoom: [this.dataZoomConfig],
+ ...option,
};
},
dataZoomConfig() {
@@ -144,6 +168,14 @@ export default {
return handleIcon ? { handleIcon } : {};
},
+ /**
+ * This method returns the earliest time value in all series of a chart.
+ * Takes a chart data with data to populate a timeseries.
+ * data should be an array of data points [t, y] where t is a ISO formatted date,
+ * and is sorted by t (time).
+ * @returns {(String|null)} earliest x value from all series, or null when the
+ * chart series data is empty.
+ */
earliestDatapoint() {
return this.chartData.reduce((acc, series) => {
const { data } = series;
@@ -230,10 +262,11 @@ export default {
this.tooltip.sha = deploy.sha.substring(0, 8);
this.tooltip.commitUrl = deploy.commitUrl;
} else {
- const { seriesName, color } = dataPoint;
+ const { seriesName, color, dataIndex } = dataPoint;
const value = yVal.toFixed(3);
this.tooltip.content.push({
name: seriesName,
+ dataIndex,
value,
color,
});
@@ -306,23 +339,27 @@ export default {
</template>
<template v-else>
<template slot="tooltipTitle">
- <div class="text-nowrap">
- {{ tooltip.title }}
- </div>
+ <slot name="tooltipTitle">
+ <div class="text-nowrap">
+ {{ tooltip.title }}
+ </div>
+ </slot>
</template>
<template slot="tooltipContent">
- <div
- v-for="(content, key) in tooltip.content"
- :key="key"
- class="d-flex justify-content-between"
- >
- <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
- {{ content.name }}
- </gl-chart-series-label>
- <div class="prepend-left-32">
- {{ content.value }}
+ <slot name="tooltipContent" :tooltip="tooltip">
+ <div
+ v-for="(content, key) in tooltip.content"
+ :key="key"
+ class="d-flex justify-content-between"
+ >
+ <gl-chart-series-label :color="isMultiSeries ? content.color : ''">
+ {{ content.name }}
+ </gl-chart-series-label>
+ <div class="prepend-left-32">
+ {{ content.value }}
+ </div>
</div>
- </div>
+ </slot>
</template>
</template>
</component>
diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue
index 2c56966f120..e3f99dbda9a 100644
--- a/app/assets/javascripts/monitoring/components/panel_type.vue
+++ b/app/assets/javascripts/monitoring/components/panel_type.vue
@@ -11,6 +11,7 @@ import {
} from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import MonitorTimeSeriesChart from './charts/time_series.vue';
+import MonitorAnomalyChart from './charts/anomaly.vue';
import MonitorSingleStatChart from './charts/single_stat.vue';
import MonitorEmptyChart from './charts/empty_chart.vue';
import TrackEventDirective from '~/vue_shared/directives/track_event';
@@ -19,7 +20,6 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils';
export default {
components: {
MonitorSingleStatChart,
- MonitorTimeSeriesChart,
MonitorEmptyChart,
Icon,
GlDropdown,
@@ -67,6 +67,12 @@ export default {
const data = new Blob([this.csvText], { type: 'text/plain' });
return window.URL.createObjectURL(data);
},
+ monitorChartComponent() {
+ if (this.isPanelType('anomaly-chart')) {
+ return MonitorAnomalyChart;
+ }
+ return MonitorTimeSeriesChart;
+ },
},
methods: {
getGraphAlerts(queries) {
@@ -93,13 +99,14 @@ export default {
v-if="isPanelType('single-stat') && graphDataHasMetrics"
:graph-data="graphData"
/>
- <monitor-time-series-chart
+ <component
+ :is="monitorChartComponent"
v-else-if="graphDataHasMetrics"
:graph-data="graphData"
:deployment-data="deploymentData"
:project-path="projectPath"
:thresholds="getGraphAlertValues(graphData.queries)"
- group-id="monitor-area-chart"
+ group-id="panel-type-chart"
>
<div class="d-flex align-items-center">
<alert-widget
@@ -141,6 +148,6 @@ export default {
</gl-dropdown-item>
</gl-dropdown>
</div>
- </monitor-time-series-chart>
+ </component>
<monitor-empty-chart v-else :graph-title="graphData.title" />
</template>
diff --git a/app/assets/javascripts/monitoring/constants.js b/app/assets/javascripts/monitoring/constants.js
index 2836fe4fc26..1a1fcdd0e66 100644
--- a/app/assets/javascripts/monitoring/constants.js
+++ b/app/assets/javascripts/monitoring/constants.js
@@ -14,13 +14,28 @@ export const graphTypes = {
};
export const symbolSizes = {
+ anomaly: 8,
default: 14,
};
+export const areaOpacityValues = {
+ default: 0.2,
+};
+
+export const colorValues = {
+ primaryColor: '#1f78d1', // $blue-500 (see variables.scss)
+ anomalySymbol: '#db3b21',
+ anomalyAreaColor: '#1f78d1',
+};
+
export const lineTypes = {
default: 'solid',
};
+export const lineWidths = {
+ default: 2,
+};
+
export const timeWindows = {
thirtyMinutes: __('30 minutes'),
threeHours: __('3 hours'),
diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js
index 00f188c1d5a..6747306a6d9 100644
--- a/app/assets/javascripts/monitoring/utils.js
+++ b/app/assets/javascripts/monitoring/utils.js
@@ -131,4 +131,20 @@ export const downloadCSVOptions = title => {
return { category, action, label: 'Chart title', property: title };
};
+/**
+ * This function validates the graph data contains exactly 3 queries plus
+ * value validations from graphDataValidatorForValues.
+ * @param {Object} isValues
+ * @param {Object} graphData the graph data response from a prometheus request
+ * @returns {boolean} true if the data is valid
+ */
+export const graphDataValidatorForAnomalyValues = graphData => {
+ const anomalySeriesCount = 3; // metric, upper, lower
+ return (
+ graphData.queries &&
+ graphData.queries.length === anomalySeriesCount &&
+ graphDataValidatorForValues(false, graphData)
+ );
+};
+
export default {};