diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-30 15:14:17 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2019-10-30 15:14:17 +0000 |
commit | 3fe9588b1c1c4fb58f8ba8e9c27244fc2fc1c103 (patch) | |
tree | d19448d010ff9d58fed14846736ee358fb6b3327 /app/assets/javascripts/monitoring | |
parent | ad8eea383406037a207c80421e6e4bfa357f8044 (diff) | |
download | gitlab-ce-3fe9588b1c1c4fb58f8ba8e9c27244fc2fc1c103.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/monitoring')
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 {}; |