diff options
Diffstat (limited to 'app/assets/javascripts/monitoring')
17 files changed, 559 insertions, 296 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/heatmap.vue b/app/assets/javascripts/monitoring/components/charts/heatmap.vue new file mode 100644 index 00000000000..b8158247e49 --- /dev/null +++ b/app/assets/javascripts/monitoring/components/charts/heatmap.vue @@ -0,0 +1,73 @@ +<script> +import { GlHeatmap } from '@gitlab/ui/dist/charts'; +import dateformat from 'dateformat'; +import PrometheusHeader from '../shared/prometheus_header.vue'; +import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue'; +import { graphDataValidatorForValues } from '../../utils'; + +export default { + components: { + GlHeatmap, + ResizableChartContainer, + PrometheusHeader, + }, + props: { + graphData: { + type: Object, + required: true, + validator: graphDataValidatorForValues.bind(null, false), + }, + containerWidth: { + type: Number, + required: true, + }, + }, + computed: { + chartData() { + return this.queries.result.reduce( + (acc, result, i) => [...acc, ...result.values.map((value, j) => [i, j, value[1]])], + [], + ); + }, + xAxisName() { + return this.graphData.x_label || ''; + }, + yAxisName() { + return this.graphData.y_label || ''; + }, + xAxisLabels() { + return this.queries.result.map(res => Object.values(res.metric)[0]); + }, + yAxisLabels() { + return this.result.values.map(val => { + const [yLabel] = val; + + return dateformat(new Date(yLabel), 'HH:MM:ss'); + }); + }, + result() { + return this.queries.result[0]; + }, + queries() { + return this.graphData.queries[0]; + }, + }, +}; +</script> +<template> + <div class="prometheus-graph col-12 col-lg-6"> + <prometheus-header :graph-title="graphData.title" /> + <resizable-chart-container> + <gl-heatmap + ref="heatmapChart" + v-bind="$attrs" + :data-series="chartData" + :x-axis-name="xAxisName" + :y-axis-name="yAxisName" + :x-axis-labels="xAxisLabels" + :y-axis-labels="yAxisLabels" + :width="containerWidth" + /> + </resizable-chart-container> + </div> +</template> diff --git a/app/assets/javascripts/monitoring/components/charts/time_series.vue b/app/assets/javascripts/monitoring/components/charts/time_series.vue index 78fe575717a..6a88c8a5ee3 100644 --- a/app/assets/javascripts/monitoring/components/charts/time_series.vue +++ b/app/assets/javascripts/monitoring/components/charts/time_series.vue @@ -1,17 +1,23 @@ <script> import { s__, __ } from '~/locale'; -import { GlLink, GlButton, GlTooltip } from '@gitlab/ui'; +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 { debounceByAnimationFrame, roundOffFloat } from '~/lib/utils/common_utils'; +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'; -let debouncedResize; - export default { components: { GlAreaChart, @@ -22,6 +28,9 @@ export default { GlLink, Icon, }, + directives: { + GlResizeObserverDirective, + }, inheritAttrs: false, props: { graphData: { @@ -29,9 +38,15 @@ export default { required: true, validator: graphDataValidatorForValues.bind(null, false), }, - containerWidth: { - type: Number, - required: true, + option: { + type: Object, + required: false, + default: () => ({}), + }, + seriesConfig: { + type: Object, + required: false, + default: () => ({}), }, deploymentData: { type: Array, @@ -99,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', @@ -138,8 +159,8 @@ export default { formatter: num => roundOffFloat(num, 3).toString(), }, }, - series: this.scatterSeries, dataZoom: [this.dataZoomConfig], + ...option, }; }, dataZoomConfig() { @@ -147,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; @@ -206,21 +235,13 @@ export default { return `${this.graphData.y_label}`; }, }, - watch: { - containerWidth: 'onResize', - }, mounted() { const graphTitleEl = this.$refs.graphTitle; if (graphTitleEl && graphTitleEl.scrollWidth > graphTitleEl.offsetWidth) { this.showTitleTooltip = true; } }, - beforeDestroy() { - window.removeEventListener('resize', debouncedResize); - }, created() { - debouncedResize = debounceByAnimationFrame(this.onResize); - window.addEventListener('resize', debouncedResize); this.setSvg('rocket'); this.setSvg('scroll-handle'); }, @@ -241,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, }); @@ -276,7 +298,7 @@ export default { </script> <template> - <div class="prometheus-graph"> + <div v-gl-resize-observer-directive="onResize" class="prometheus-graph"> <div class="prometheus-graph-header"> <h5 ref="graphTitle" @@ -317,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/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index b4ea415bb51..26e2c2568c1 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -11,7 +11,7 @@ import { GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import createFlash from '~/flash'; import Icon from '~/vue_shared/components/icon.vue'; import { getParameterValues, mergeUrlParams, redirectTo } from '~/lib/utils/url_utility'; @@ -22,12 +22,9 @@ import MonitorTimeSeriesChart from './charts/time_series.vue'; import MonitorSingleStatChart from './charts/single_stat.vue'; import GraphGroup from './graph_group.vue'; import EmptyState from './empty_state.vue'; -import { sidebarAnimationDuration } from '../constants'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { getTimeDiff, isValidDate, downloadCSVOptions, generateLinkToChartOptions } from '../utils'; -let sidebarMutationObserver; - export default { components: { VueDraggable, @@ -167,10 +164,10 @@ export default { data() { return { state: 'gettingStarted', - elWidth: 0, formIsValid: null, selectedTimeWindow: {}, isRearrangingPanels: false, + hasValidDates: true, }; }, computed: { @@ -178,7 +175,7 @@ export default { return this.customMetricsAvailable && this.customMetricsPath.length; }, ...mapState('monitoringDashboard', [ - 'groups', + 'dashboard', 'emptyState', 'showEmptyState', 'environments', @@ -189,10 +186,15 @@ export default { 'additionalPanelTypesEnabled', ]), firstDashboard() { - return this.allDashboards[0] || {}; + return this.environmentsEndpoint.length > 0 && this.allDashboards.length > 0 + ? this.allDashboards[0] + : {}; + }, + selectedDashboard() { + return this.allDashboards.find(d => d.path === this.currentDashboard) || this.firstDashboard; }, selectedDashboardText() { - return this.currentDashboard || this.firstDashboard.display_name; + return this.selectedDashboard.display_name; }, showRearrangePanelsBtn() { return !this.showEmptyState && this.rearrangePanelsAvailable; @@ -200,8 +202,13 @@ export default { addingMetricsAvailable() { return IS_EE && this.canAddMetrics && !this.showEmptyState; }, - alertWidgetAvailable() { - return IS_EE && this.prometheusAlertsAvailable && this.alertsEndpoint; + hasHeaderButtons() { + return ( + this.addingMetricsAvailable || + this.showRearrangePanelsBtn || + this.selectedDashboard.can_edit || + this.externalDashboardUrl.length + ); }, }, created() { @@ -214,11 +221,6 @@ export default { projectPath: this.projectPath, }); }, - beforeDestroy() { - if (sidebarMutationObserver) { - sidebarMutationObserver.disconnect(); - } - }, mounted() { if (!this.hasMetrics) { this.setGettingStartedEmptyState(); @@ -235,17 +237,12 @@ export default { this.selectedTimeWindow = range; if (!isValidDate(start) || !isValidDate(end)) { + this.hasValidDates = false; this.showInvalidDateError(); } else { + this.hasValidDates = true; this.fetchData(range); } - - sidebarMutationObserver = new MutationObserver(this.onSidebarMutation); - sidebarMutationObserver.observe(document.querySelector('.layout-page'), { - attributes: true, - childList: false, - subtree: false, - }); } }, methods: { @@ -253,43 +250,25 @@ export default { 'fetchData', 'setGettingStartedEmptyState', 'setEndpoints', - 'setDashboardEnabled', + 'setPanelGroupMetrics', ]), chartsWithData(charts) { - if (!this.useDashboardEndpoint) { - return charts; - } return charts.filter(chart => chart.metrics.some(metric => this.metricsWithData.includes(metric.metric_id)), ); }, - csvText(graphData) { - const chartData = graphData.queries[0].result[0].values; - const yLabel = graphData.y_label; - const header = `timestamp,${yLabel}\r\n`; // eslint-disable-line @gitlab/i18n/no-non-i18n-strings - return chartData.reduce((csv, data) => { - const row = data.join(','); - return `${csv}${row}\r\n`; - }, header); - }, - downloadCsv(graphData) { - const data = new Blob([this.csvText(graphData)], { type: 'text/plain' }); - return window.URL.createObjectURL(data); - }, - // TODO: BEGIN, Duplicated code with panel_type until feature flag is removed - // Issue number: https://gitlab.com/gitlab-org/gitlab-foss/issues/63845 - getGraphAlerts(queries) { - if (!this.allAlerts) return {}; - const metricIdsForChart = queries.map(q => q.metricId); - return _.pick(this.allAlerts, alert => metricIdsForChart.includes(alert.metricId)); - }, - getGraphAlertValues(queries) { - return Object.values(this.getGraphAlerts(queries)); - }, - showToast() { - this.$toast.show(__('Link copied')); - }, - // TODO: END + updateMetrics(key, metrics) { + this.setPanelGroupMetrics({ + metrics, + key, + }); + }, + removeMetric(key, metrics, graphIndex) { + this.setPanelGroupMetrics({ + metrics: metrics.filter((v, i) => i !== graphIndex), + key, + }); + }, removeGraph(metrics, graphIndex) { // At present graphs will not be removed, they should removed using the vuex store // See https://gitlab.com/gitlab-org/gitlab/issues/27835 @@ -306,11 +285,6 @@ export default { hideAddMetricModal() { this.$refs.addMetricModal.hide(); }, - onSidebarMutation() { - setTimeout(() => { - this.elWidth = this.$el.clientWidth; - }, sidebarAnimationDuration); - }, toggleRearrangingPanels() { this.isRearrangingPanels = !this.isRearrangingPanels; }, @@ -389,7 +363,7 @@ export default { </gl-form-group> <gl-form-group - v-if="!showEmptyState" + v-if="hasValidDates" :label="s__('Metrics|Show last')" label-size="sm" label-for="monitor-time-window-dropdown" @@ -403,7 +377,7 @@ export default { </template> <gl-form-group - v-if="addingMetricsAvailable || showRearrangePanelsBtn || externalDashboardUrl.length" + v-if="hasHeaderButtons" label-for="prometheus-graphs-dropdown-buttons" class="dropdown-buttons col-md d-md-flex col-lg d-lg-flex align-items-end" > @@ -451,6 +425,14 @@ export default { </gl-modal> <gl-button + v-if="selectedDashboard.can_edit" + class="mt-1 js-edit-link" + :href="selectedDashboard.project_blob_path" + > + {{ __('Edit dashboard') }} + </gl-button> + + <gl-button v-if="externalDashboardUrl.length" class="mt-1 js-external-dashboard-link" variant="primary" @@ -468,116 +450,46 @@ export default { <div v-if="!showEmptyState"> <graph-group - v-for="(groupData, index) in groups" + v-for="(groupData, index) in dashboard.panel_groups" :key="`${groupData.group}.${groupData.priority}`" :name="groupData.group" :show-panels="showPanels" :collapse-group="groupHasData(groupData)" > - <template v-if="additionalPanelTypesEnabled"> - <vue-draggable - :list="groupData.metrics" - group="metrics-dashboard" - :component-data="{ attrs: { class: 'row mx-0 w-100' } }" - :disabled="!isRearrangingPanels" + <vue-draggable + :value="groupData.metrics" + group="metrics-dashboard" + :component-data="{ attrs: { class: 'row mx-0 w-100' } }" + :disabled="!isRearrangingPanels" + @input="updateMetrics(groupData.key, $event)" + > + <div + v-for="(graphData, graphIndex) in groupData.metrics" + :key="`panel-type-${graphIndex}`" + class="col-12 col-lg-6 px-2 mb-2 draggable" + :class="{ 'draggable-enabled': isRearrangingPanels }" > - <div - v-for="(graphData, graphIndex) in groupData.metrics" - :key="`panel-type-${graphIndex}`" - class="col-12 col-lg-6 px-2 mb-2 draggable" - :class="{ 'draggable-enabled': isRearrangingPanels }" - > - <div class="position-relative draggable-panel js-draggable-panel"> - <div - v-if="isRearrangingPanels" - class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" - @click="removeGraph(groupData.metrics, graphIndex)" - > - <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" - ><icon name="close" - /></a> - </div> - - <panel-type - :clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - :graph-data="graphData" - :dashboard-width="elWidth" - :alerts-endpoint="alertsEndpoint" - :prometheus-alerts-available="prometheusAlertsAvailable" - :index="`${index}-${graphIndex}`" - /> + <div class="position-relative draggable-panel js-draggable-panel"> + <div + v-if="isRearrangingPanels" + class="draggable-remove js-draggable-remove p-2 w-100 position-absolute d-flex justify-content-end" + @click="removeGraph(groupData.metrics, graphIndex)" + > + <a class="mx-2 p-2 draggable-remove-link" :aria-label="__('Remove')" + ><icon name="close" + /></a> </div> - </div> - </vue-draggable> - </template> - <template v-else> - <monitor-time-series-chart - v-for="(graphData, graphIndex) in chartsWithData(groupData.metrics)" - :key="graphIndex" - class="col-12 col-lg-6 pb-3" - :graph-data="graphData" - :deployment-data="deploymentData" - :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="elWidth" - :project-path="projectPath" - group-id="monitor-time-series-chart" - > - <div - class="d-flex align-items-center" - :class="alertWidgetAvailable ? 'justify-content-between' : 'justify-content-end'" - > - <alert-widget - v-if="alertWidgetAvailable && graphData" - :modal-id="`alert-modal-${index}-${graphIndex}`" + + <panel-type + :clipboard-text="generateLink(groupData.group, graphData.title, graphData.y_label)" + :graph-data="graphData" :alerts-endpoint="alertsEndpoint" - :relevant-queries="graphData.queries" - :alerts-to-manage="getGraphAlerts(graphData.queries)" - @setAlerts="setAlerts" + :prometheus-alerts-available="prometheusAlertsAvailable" + :index="`${index}-${graphIndex}`" /> - <gl-dropdown - v-gl-tooltip - class="ml-2 mr-3" - toggle-class="btn btn-transparent border-0" - :right="true" - :no-caret="true" - :title="__('More actions')" - > - <template slot="button-content"> - <icon name="ellipsis_v" class="text-secondary" /> - </template> - <gl-dropdown-item - v-track-event="downloadCSVOptions(graphData.title)" - :href="downloadCsv(graphData)" - download="chart_metrics.csv" - > - {{ __('Download CSV') }} - </gl-dropdown-item> - <gl-dropdown-item - v-track-event=" - generateLinkToChartOptions( - generateLink(groupData.group, graphData.title, graphData.y_label), - ) - " - class="js-chart-link" - :data-clipboard-text=" - generateLink(groupData.group, graphData.title, graphData.y_label) - " - @click="showToast" - > - {{ __('Generate link to chart') }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="alertWidgetAvailable" - v-gl-modal="`alert-modal-${index}-${graphIndex}`" - > - {{ __('Alerts') }} - </gl-dropdown-item> - </gl-dropdown> </div> - </monitor-time-series-chart> - </template> + </div> + </vue-draggable> </graph-group> </div> <empty-state diff --git a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue index 4616a767295..8749019c5cd 100644 --- a/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue +++ b/app/assets/javascripts/monitoring/components/date_time_picker/date_time_picker.vue @@ -55,17 +55,13 @@ export default { }; }, }, + watch: { + selectedTimeWindow() { + this.verifyTimeRange(); + }, + }, mounted() { - const range = getTimeWindow(this.selectedTimeWindow); - if (range) { - this.selectedTimeWindowText = this.timeWindows[range]; - } else { - this.customTime = { - from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), - to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), - }; - this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); - } + this.verifyTimeRange(); }, methods: { activeTimeWindow(key) { @@ -87,6 +83,18 @@ export default { closeDropdown() { this.$refs.dropdown.hide(); }, + verifyTimeRange() { + const range = getTimeWindow(this.selectedTimeWindow); + if (range) { + this.selectedTimeWindowText = this.timeWindows[range]; + } else { + this.customTime = { + from: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.start)), + to: truncateZerosInDateTime(ISODateToString(this.selectedTimeWindow.end)), + }; + this.selectedTimeWindowText = sprintf(s__('%{from} to %{to}'), this.customTime); + } + }, }, }; </script> diff --git a/app/assets/javascripts/monitoring/components/embed.vue b/app/assets/javascripts/monitoring/components/embed.vue index 7857aaa6ecc..f75839c7c6b 100644 --- a/app/assets/javascripts/monitoring/components/embed.vue +++ b/app/assets/javascripts/monitoring/components/embed.vue @@ -35,9 +35,9 @@ export default { }; }, computed: { - ...mapState('monitoringDashboard', ['groups', 'metricsWithData']), + ...mapState('monitoringDashboard', ['dashboard', 'metricsWithData']), charts() { - const groupWithMetrics = this.groups.find(group => + const groupWithMetrics = this.dashboard.panel_groups.find(group => group.metrics.find(chart => this.chartHasData(chart)), ) || { metrics: [] }; @@ -78,9 +78,6 @@ export default { }, sidebarAnimationDuration); }, setInitialState() { - this.setFeatureFlags({ - prometheusEndpointEnabled: true, - }); this.setEndpoints({ dashboardEndpoint: removeParams(['start', 'end'], this.dashboardUrl), }); diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index ee3a2bae79b..3cb6ccb64b1 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -45,7 +45,7 @@ export default { <div v-if="showPanels" class="card prometheus-panel"> <div class="card-header d-flex align-items-center"> <h4 class="flex-grow-1">{{ name }}</h4> - <a role="button" @click="collapse"> + <a role="button" class="js-graph-group-toggle" @click="collapse"> <icon :size="16" :aria-label="__('Toggle collapse')" :name="caretIcon" /> </a> </div> diff --git a/app/assets/javascripts/monitoring/components/panel_type.vue b/app/assets/javascripts/monitoring/components/panel_type.vue index 1a14d06f4c8..cafb4b0b479 100644 --- a/app/assets/javascripts/monitoring/components/panel_type.vue +++ b/app/assets/javascripts/monitoring/components/panel_type.vue @@ -11,7 +11,9 @@ 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 MonitorHeatmapChart from './charts/heatmap.vue'; import MonitorEmptyChart from './charts/empty_chart.vue'; import TrackEventDirective from '~/vue_shared/directives/track_event'; import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; @@ -19,7 +21,7 @@ import { downloadCSVOptions, generateLinkToChartOptions } from '../utils'; export default { components: { MonitorSingleStatChart, - MonitorTimeSeriesChart, + MonitorHeatmapChart, MonitorEmptyChart, Icon, GlDropdown, @@ -40,10 +42,6 @@ export default { type: Object, required: true, }, - dashboardWidth: { - type: Number, - required: true, - }, index: { type: String, required: false, @@ -71,6 +69,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) { @@ -97,14 +101,19 @@ export default { v-if="isPanelType('single-stat') && graphDataHasMetrics" :graph-data="graphData" /> - <monitor-time-series-chart + <monitor-heatmap-chart + v-else-if="isPanelType('heatmap') && graphDataHasMetrics" + :graph-data="graphData" + :container-width="dashboardWidth" + /> + <component + :is="monitorChartComponent" v-else-if="graphDataHasMetrics" :graph-data="graphData" :deployment-data="deploymentData" :project-path="projectPath" :thresholds="getGraphAlertValues(graphData.queries)" - :container-width="dashboardWidth" - group-id="monitor-area-chart" + group-id="panel-type-chart" > <div class="d-flex align-items-center"> <alert-widget @@ -146,6 +155,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/components/shared/prometheus_header.vue b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue new file mode 100644 index 00000000000..153c8f389db --- /dev/null +++ b/app/assets/javascripts/monitoring/components/shared/prometheus_header.vue @@ -0,0 +1,15 @@ +<script> +export default { + props: { + graphTitle: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <div class="prometheus-graph-header"> + <h5 class="prometheus-graph-title js-graph-title">{{ graphTitle }}</h5> + </div> +</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/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index 6aa1fb5e9c6..a14145d480b 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -11,13 +11,6 @@ export default (props = {}) => { const el = document.getElementById('prometheus-graphs'); if (el && el.dataset) { - if (gon.features) { - store.dispatch('monitoringDashboard/setFeatureFlags', { - prometheusEndpointEnabled: gon.features.environmentMetricsUsePrometheusEndpoint, - additionalPanelTypesEnabled: gon.features.environmentMetricsAdditionalPanelTypes, - }); - } - const [currentDashboard] = getParameterValues('dashboard'); // eslint-disable-next-line no-new diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 2cf34ddb45b..6a8e3cc82f5 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -7,7 +7,7 @@ import { s__, __ } from '../../locale'; const MAX_REQUESTS = 3; -function backOffRequest(makeRequestCallback) { +export function backOffRequest(makeRequestCallback) { let requestCounter = 0; return backOff((next, stop) => { makeRequestCallback() @@ -35,14 +35,6 @@ export const setEndpoints = ({ commit }, endpoints) => { commit(types.SET_ENDPOINTS, endpoints); }; -export const setFeatureFlags = ( - { commit }, - { prometheusEndpointEnabled, additionalPanelTypesEnabled }, -) => { - commit(types.SET_DASHBOARD_ENABLED, prometheusEndpointEnabled); - commit(types.SET_ADDITIONAL_PANEL_TYPES_ENABLED, additionalPanelTypesEnabled); -}; - export const setShowErrorBanner = ({ commit }, enabled) => { commit(types.SET_SHOW_ERROR_BANNER, enabled); }; @@ -79,29 +71,7 @@ export const fetchData = ({ dispatch }, params) => { dispatch('fetchEnvironmentsData'); }; -export const fetchMetricsData = ({ state, dispatch }, params) => { - if (state.useDashboardEndpoint) { - return dispatch('fetchDashboard', params); - } - - dispatch('requestMetricsData'); - - return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) - .then(resp => resp.data) - .then(response => { - if (!response || !response.data || !response.success) { - dispatch('receiveMetricsDataFailure', null); - createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint')); - } - dispatch('receiveMetricsDataSuccess', response.data); - }) - .catch(error => { - dispatch('receiveMetricsDataFailure', error); - if (state.setShowErrorBanner) { - createFlash(s__('Metrics|There was an error while retrieving metrics')); - } - }); -}; +export const fetchMetricsData = ({ dispatch }, params) => dispatch('fetchDashboard', params); export const fetchDashboard = ({ state, dispatch }, params) => { dispatch('requestMetricsDashboard'); @@ -111,11 +81,13 @@ export const fetchDashboard = ({ state, dispatch }, params) => { params.dashboard = state.currentDashboard; } - return axios - .get(state.dashboardEndpoint, { params }) + return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) .then(response => { - dispatch('receiveMetricsDashboardSuccess', { response, params }); + dispatch('receiveMetricsDashboardSuccess', { + response, + params, + }); }) .catch(error => { dispatch('receiveMetricsDashboardFailure', error); @@ -166,7 +138,7 @@ export const fetchPrometheusMetrics = ({ state, commit, dispatch }, params) => { commit(types.REQUEST_METRICS_DATA); const promises = []; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.panels.forEach(panel => { panel.metrics.forEach(metric => { promises.push(dispatch('fetchPrometheusMetric', { metric, params })); @@ -221,5 +193,15 @@ export const fetchEnvironmentsData = ({ state, dispatch }) => { }); }; +/** + * Set a new array of metrics to a panel group + * @param {*} data An object containing + * - `key` with a unique panel key + * - `metrics` with the metrics array + */ +export const setPanelGroupMetrics = ({ commit }, data) => { + commit(types.SET_PANEL_GROUP_METRICS, data); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 9c546427c6e..fa15a2ba800 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -9,10 +9,9 @@ export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCC export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const SET_QUERY_RESULT = 'SET_QUERY_RESULT'; export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; -export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED'; -export const SET_ADDITIONAL_PANEL_TYPES_ENABLED = 'SET_ADDITIONAL_PANEL_TYPES_ENABLED'; export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; +export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 320b33d3d69..696af5aed75 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,6 +1,7 @@ import Vue from 'vue'; +import { slugify } from '~/lib/utils/text_utility'; import * as types from './mutation_types'; -import { normalizeMetrics, sortMetrics, normalizeMetric, normalizeQueryResult } from './utils'; +import { normalizeMetrics, normalizeMetric, normalizeQueryResult } from './utils'; const normalizePanel = panel => panel.metrics.map(normalizeMetric); @@ -10,10 +11,12 @@ export default { state.showEmptyState = true; }, [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { - state.groups = groupData.map(group => { + state.dashboard.panel_groups = groupData.map((group, i) => { + const key = `${slugify(group.group || 'default')}-${i}`; let { metrics = [], panels = [] } = group; // each panel has metric information that needs to be normalized + panels = panels.map(panel => ({ ...panel, metrics: normalizePanel(panel), @@ -22,24 +25,21 @@ export default { // for backwards compatibility, and to limit Vue template changes: // for each group alias panels to metrics // for each panel alias metrics to queries - if (state.useDashboardEndpoint) { - metrics = panels.map(panel => ({ - ...panel, - queries: panel.metrics, - })); - } + metrics = panels.map(panel => ({ + ...panel, + queries: panel.metrics, + })); return { ...group, panels, - metrics: normalizeMetrics(sortMetrics(metrics)), + key, + metrics: normalizeMetrics(metrics), }; }); - if (!state.groups.length) { + if (!state.dashboard.panel_groups.length) { state.emptyState = 'noData'; - } else { - state.showEmptyState = false; } }, [types.RECEIVE_METRICS_DATA_FAILURE](state, error) { @@ -65,7 +65,7 @@ export default { state.showEmptyState = false; - state.groups.forEach(group => { + state.dashboard.panel_groups.forEach(group => { group.metrics.forEach(metric => { metric.queries.forEach(query => { if (query.metric_id === metricId) { @@ -86,9 +86,6 @@ export default { state.currentDashboard = endpoints.currentDashboard; state.projectPath = endpoints.projectPath; }, - [types.SET_DASHBOARD_ENABLED](state, enabled) { - state.useDashboardEndpoint = enabled; - }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { state.emptyState = 'gettingStarted'; }, @@ -97,12 +94,13 @@ export default { state.emptyState = 'noData'; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { - state.allDashboards = dashboards; - }, - [types.SET_ADDITIONAL_PANEL_TYPES_ENABLED](state, enabled) { - state.additionalPanelTypesEnabled = enabled; + state.allDashboards = dashboards || []; }, [types.SET_SHOW_ERROR_BANNER](state, enabled) { state.showErrorBanner = enabled; }, + [types.SET_PANEL_GROUP_METRICS](state, payload) { + const panelGroup = state.dashboard.panel_groups.find(pg => payload.key === pg.key); + panelGroup.metrics = payload.metrics; + }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index e894e988f6a..87e94311176 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -7,12 +7,12 @@ export default () => ({ environmentsEndpoint: null, deploymentsEndpoint: null, dashboardEndpoint: invalidUrl, - useDashboardEndpoint: false, - additionalPanelTypesEnabled: false, emptyState: 'gettingStarted', showEmptyState: true, showErrorBanner: true, - groups: [], + dashboard: { + panel_groups: [], + }, deploymentData: [], environments: [], metricsWithData: [], diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index a19829f0c65..8a396b15a31 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -82,12 +82,6 @@ export const normalizeMetric = (metric = {}) => 'id', ); -export const sortMetrics = metrics => - _.chain(metrics) - .sortBy('title') - .sortBy('weight') - .value(); - export const normalizeQueryResult = timeSeries => { let normalizedResult = {}; diff --git a/app/assets/javascripts/monitoring/utils.js b/app/assets/javascripts/monitoring/utils.js index 00f188c1d5a..2ae1647011d 100644 --- a/app/assets/javascripts/monitoring/utils.js +++ b/app/assets/javascripts/monitoring/utils.js @@ -1,7 +1,6 @@ import dateformat from 'dateformat'; import { secondsIn, dateTimePickerRegex, dateFormats } from './constants'; - -const secondsToMilliseconds = seconds => seconds * 1000; +import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; export const getTimeDiff = timeWindow => { const end = Math.floor(Date.now() / 1000); // convert milliseconds to seconds @@ -131,4 +130,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 {}; |