diff options
10 files changed, 277 insertions, 153 deletions
diff --git a/app/assets/javascripts/lib/utils/number_utils.js b/app/assets/javascripts/lib/utils/number_utils.js new file mode 100644 index 00000000000..e2bf69ee52e --- /dev/null +++ b/app/assets/javascripts/lib/utils/number_utils.js @@ -0,0 +1,34 @@ +/* eslint-disable import/prefer-default-export */ + +/** + * Function that allows a number with an X amount of decimals + * to be formatted in the following fashion: + * * For 1 digit to the left of the decimal point and X digits to the right of it + * * * Show 3 digits to the right + * * For 2 digits to the left of the decimal point and X digits to the right of it + * * * Show 2 digits to the right +*/ +export function formatRelevantDigits(number) { + let digitsLeft = ''; + let relevantDigits = 0; + let formattedNumber = ''; + if (!isNaN(Number(number))) { + digitsLeft = number.split('.')[0]; + switch (digitsLeft.length) { + case 1: + relevantDigits = 3; + break; + case 2: + relevantDigits = 2; + break; + case 3: + relevantDigits = 1; + break; + default: + relevantDigits = 4; + break; + } + formattedNumber = Number(number).toFixed(relevantDigits); + } + return formattedNumber; +} diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js index 844a0785bc9..a6ffa0f59de 100644 --- a/app/assets/javascripts/monitoring/prometheus_graph.js +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -1,9 +1,9 @@ -/* eslint-disable no-new*/ +/* eslint-disable no-new */ /* global Flash */ import d3 from 'd3'; import statusCodes from '~/lib/utils/http_status'; -import '../lib/utils/common_utils'; +import { formatRelevantDigits } from '~/lib/utils/number_utils'; import '../flash'; const prometheusGraphsContainer = '.prometheus-graph'; @@ -21,19 +21,19 @@ class PrometheusGraph { const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + extraAddedWidthParent; this.originalWidth = parentContainerWidth; - this.originalHeight = 400; + this.originalHeight = 330; this.width = parentContainerWidth - this.margin.left - this.margin.right; - this.height = 400 - this.margin.top - this.margin.bottom; + this.height = this.originalHeight - this.margin.top - this.margin.bottom; this.backOffRequestCounter = 0; this.configureGraph(); this.init(); } createGraph() { - Object.keys(this.data).forEach((key) => { - const value = this.data[key]; - if (value.length > 0) { - this.plotValues(value, key); + Object.keys(this.graphSpecificProperties).forEach((key) => { + const value = this.graphSpecificProperties[key]; + if (value.data.length > 0) { + this.plotValues(key); } }); } @@ -49,53 +49,56 @@ class PrometheusGraph { }); } - plotValues(valuesToPlot, key) { + plotValues(key) { + const graphSpecifics = this.graphSpecificProperties[key]; + const x = d3.time.scale() .range([0, this.width]); const y = d3.scale.linear() .range([this.height, 0]); - const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + graphSpecifics.xScale = x; + graphSpecifics.yScale = y; - const graphSpecifics = this.graphSpecificProperties[key]; + const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; const chart = d3.select(prometheusGraphContainer) - .attr('width', this.width + this.margin.left + this.margin.right) - .attr('height', this.height + this.margin.bottom + this.margin.top) - .append('g') - .attr('transform', `translate(${this.margin.left},${this.margin.top})`); + .attr('width', this.width + this.margin.left + this.margin.right) + .attr('height', this.height + this.margin.bottom + this.margin.top) + .append('g') + .attr('transform', `translate(${this.margin.left},${this.margin.top})`); const axisLabelContainer = d3.select(prometheusGraphContainer) - .attr('width', this.originalWidth + this.marginLabelContainer.left + this.marginLabelContainer.right) - .attr('height', this.originalHeight + this.marginLabelContainer.bottom + this.marginLabelContainer.top) + .attr('width', this.originalWidth) + .attr('height', this.originalHeight) .append('g') .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`); - x.domain(d3.extent(valuesToPlot, d => d.time)); - y.domain([0, d3.max(valuesToPlot.map(metricValue => metricValue.value))]); + x.domain(d3.extent(graphSpecifics.data, d => d.time)); + y.domain([0, d3.max(graphSpecifics.data.map(metricValue => metricValue.value))]); const xAxis = d3.svg.axis() - .scale(x) - .ticks(this.commonGraphProperties.axis_no_ticks) - .orient('bottom'); + .scale(x) + .ticks(this.commonGraphProperties.axis_no_ticks) + .orient('bottom'); const yAxis = d3.svg.axis() - .scale(y) - .ticks(this.commonGraphProperties.axis_no_ticks) - .tickSize(-this.width) - .orient('left'); + .scale(y) + .ticks(this.commonGraphProperties.axis_no_ticks) + .tickSize(-this.width) + .orient('left'); this.createAxisLabelContainers(axisLabelContainer, key); chart.append('g') - .attr('class', 'x-axis') - .attr('transform', `translate(0,${this.height})`) - .call(xAxis); + .attr('class', 'x-axis') + .attr('transform', `translate(0,${this.height})`) + .call(xAxis); chart.append('g') - .attr('class', 'y-axis') - .call(yAxis); + .attr('class', 'y-axis') + .call(yAxis); const area = d3.svg.area() .x(d => x(d.time)) @@ -108,13 +111,13 @@ class PrometheusGraph { .y(d => y(d.value)); chart.append('path') - .datum(valuesToPlot) - .attr('d', area) - .attr('class', 'metric-area') - .attr('fill', graphSpecifics.area_fill_color); + .datum(graphSpecifics.data) + .attr('d', area) + .attr('class', 'metric-area') + .attr('fill', graphSpecifics.area_fill_color); chart.append('path') - .datum(valuesToPlot) + .datum(graphSpecifics.data) .attr('class', 'metric-line') .attr('stroke', graphSpecifics.line_color) .attr('fill', 'none') @@ -126,7 +129,7 @@ class PrometheusGraph { .attr('class', 'prometheus-graph-overlay') .attr('width', this.width) .attr('height', this.height) - .on('mousemove', this.handleMouseOverGraph.bind(this, x, y, valuesToPlot, chart, prometheusGraphContainer, key)); + .on('mousemove', this.handleMouseOverGraph.bind(this, prometheusGraphContainer)); } // The legends from the metric @@ -134,128 +137,150 @@ class PrometheusGraph { const graphSpecifics = this.graphSpecificProperties[key]; axisLabelContainer.append('line') - .attr('class', 'label-x-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 0, - y1: this.originalHeight - this.marginLabelContainer.top, - x2: this.originalWidth - this.margin.right, - y2: this.originalHeight - this.marginLabelContainer.top, - }); + .attr('class', 'label-x-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 10, + y1: this.originalHeight - this.margin.top, + x2: (this.originalWidth - this.margin.right) + 10, + y2: this.originalHeight - this.margin.top, + }); axisLabelContainer.append('line') - .attr('class', 'label-y-axis-line') - .attr('stroke', '#000000') - .attr('stroke-width', '1') - .attr({ - x1: 0, - y1: 0, - x2: 0, - y2: this.originalHeight - this.marginLabelContainer.top, - }); + .attr('class', 'label-y-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 10, + y1: 0, + x2: 10, + y2: this.originalHeight - this.margin.top, + }); + + axisLabelContainer.append('rect') + .attr('class', 'rect-axis-text') + .attr('x', 0) + .attr('y', 50) + .attr('width', 30) + .attr('height', 150); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('text-anchor', 'middle') - .attr('transform', `translate(15, ${(this.originalHeight - this.marginLabelContainer.top) / 2}) rotate(-90)`) - .text(graphSpecifics.graph_legend_title); + .attr('class', 'label-axis-text') + .attr('text-anchor', 'middle') + .attr('transform', `translate(15, ${(this.originalHeight - this.margin.top) / 2}) rotate(-90)`) + .text(graphSpecifics.graph_legend_title); axisLabelContainer.append('rect') - .attr('class', 'rect-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - this.marginLabelContainer.top - 20) - .attr('width', 30) - .attr('height', 80); + .attr('class', 'rect-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - 100) + .attr('width', 30) + .attr('height', 80); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('x', (this.originalWidth / 2) - this.margin.right) - .attr('y', this.originalHeight - this.marginLabelContainer.top) - .attr('dy', '.35em') - .text('Time'); + .attr('class', 'label-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.margin.top) + .attr('dy', '.35em') + .text('Time'); // Legends // Metric Usage axisLabelContainer.append('rect') - .attr('x', this.originalWidth - 170) - .attr('y', (this.originalHeight / 2) - 60) - .style('fill', graphSpecifics.area_fill_color) - .attr('width', 20) - .attr('height', 35); + .attr('x', this.originalWidth - 170) + .attr('y', (this.originalHeight / 2) - 60) + .style('fill', graphSpecifics.area_fill_color) + .attr('width', 20) + .attr('height', 35); axisLabelContainer.append('text') - .attr('class', 'label-axis-text') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 50) - .text('Average'); + .attr('class', 'text-metric-title') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 50) + .text('Average'); axisLabelContainer.append('text') - .attr('class', 'text-metric-usage') - .attr('x', this.originalWidth - 140) - .attr('y', (this.originalHeight / 2) - 25); + .attr('class', 'text-metric-usage') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 25); } - handleMouseOverGraph(x, y, valuesToPlot, chart, prometheusGraphContainer, key) { + handleMouseOverGraph(prometheusGraphContainer) { const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`); - const timeValueFromOverlay = x.invert(d3.mouse(rectOverlay)[0]); - const timeValueIndex = bisectDate(valuesToPlot, timeValueFromOverlay, 1); - const d0 = valuesToPlot[timeValueIndex - 1]; - const d1 = valuesToPlot[timeValueIndex]; - const currentData = timeValueFromOverlay - d0.time > d1.time - timeValueFromOverlay ? d1 : d0; - const maxValueMetric = y(d3.max(valuesToPlot.map(metricValue => metricValue.value))); - const currentTimeCoordinate = x(currentData.time); - const graphSpecifics = this.graphSpecificProperties[key]; - // Remove the current selectors - d3.selectAll(`${prometheusGraphContainer} .selected-metric-line`).remove(); - d3.selectAll(`${prometheusGraphContainer} .circle-metric`).remove(); - d3.selectAll(`${prometheusGraphContainer} .rect-text-metric`).remove(); - d3.selectAll(`${prometheusGraphContainer} .text-metric`).remove(); - - chart.append('line') - .attr('class', 'selected-metric-line') - .attr({ - x1: currentTimeCoordinate, - y1: y(0), - x2: currentTimeCoordinate, - y2: maxValueMetric, - }); + const currentXCoordinate = d3.mouse(rectOverlay)[0]; + + Object.keys(this.graphSpecificProperties).forEach((key) => { + const currentGraphProps = this.graphSpecificProperties[key]; + const timeValueOverlay = currentGraphProps.xScale.invert(currentXCoordinate); + const overlayIndex = bisectDate(currentGraphProps.data, timeValueOverlay, 1); + const d0 = currentGraphProps.data[overlayIndex - 1]; + const d1 = currentGraphProps.data[overlayIndex]; + const evalTime = timeValueOverlay - d0.time > d1.time - timeValueOverlay; + const currentData = evalTime ? d1 : d0; + const currentTimeCoordinate = currentGraphProps.xScale(currentData.time); + const currentPrometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + const maxValueFromData = d3.max(currentGraphProps.data.map(metricValue => metricValue.value)); + const maxMetricValue = currentGraphProps.yScale(maxValueFromData); + + // Clear up all the pieces of the flag + d3.selectAll(`${currentPrometheusGraphContainer} .selected-metric-line`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .circle-metric`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .rect-text-metric`).remove(); + d3.selectAll(`${currentPrometheusGraphContainer} .text-metric`).remove(); + + const currentChart = d3.select(currentPrometheusGraphContainer).select('g'); + currentChart.append('line') + .attr('class', 'selected-metric-line') + .attr({ + x1: currentTimeCoordinate, + y1: currentGraphProps.yScale(0), + x2: currentTimeCoordinate, + y2: maxMetricValue, + }); + + currentChart.append('circle') + .attr('class', 'circle-metric') + .attr('fill', currentGraphProps.line_color) + .attr('cx', currentTimeCoordinate) + .attr('cy', currentGraphProps.yScale(currentData.value)) + .attr('r', this.commonGraphProperties.circle_radius_metric); + + // The little box with text + const rectTextMetric = currentChart.append('g') + .attr('class', 'rect-text-metric') + .attr('translate', `(${currentTimeCoordinate}, ${currentGraphProps.yScale(currentData.value)})`); + + rectTextMetric.append('rect') + .attr('class', 'rect-metric') + .attr('x', currentTimeCoordinate + 10) + .attr('y', maxMetricValue) + .attr('width', this.commonGraphProperties.rect_text_width) + .attr('height', this.commonGraphProperties.rect_text_height); + + rectTextMetric.append('text') + .attr('class', 'text-metric') + .attr('x', currentTimeCoordinate + 35) + .attr('y', maxMetricValue + 35) + .text(timeFormat(currentData.time)); + + rectTextMetric.append('text') + .attr('class', 'text-metric-date') + .attr('x', currentTimeCoordinate + 15) + .attr('y', maxMetricValue + 15) + .text(dayFormat(currentData.time)); + + let currentMetricValue = formatRelevantDigits(currentData.value); + if (key === 'cpu_values') { + currentMetricValue = `${currentMetricValue}%`; + } else { + currentMetricValue = `${currentMetricValue} MB`; + } - chart.append('circle') - .attr('class', 'circle-metric') - .attr('fill', graphSpecifics.line_color) - .attr('cx', currentTimeCoordinate) - .attr('cy', y(currentData.value)) - .attr('r', this.commonGraphProperties.circle_radius_metric); - - // The little box with text - const rectTextMetric = chart.append('g') - .attr('class', 'rect-text-metric') - .attr('translate', `(${currentTimeCoordinate}, ${y(currentData.value)})`); - - rectTextMetric.append('rect') - .attr('class', 'rect-metric') - .attr('x', currentTimeCoordinate + 10) - .attr('y', maxValueMetric) - .attr('width', this.commonGraphProperties.rect_text_width) - .attr('height', this.commonGraphProperties.rect_text_height); - - rectTextMetric.append('text') - .attr('class', 'text-metric') - .attr('x', currentTimeCoordinate + 35) - .attr('y', maxValueMetric + 35) - .text(timeFormat(currentData.time)); - - rectTextMetric.append('text') - .attr('class', 'text-metric-date') - .attr('x', currentTimeCoordinate + 15) - .attr('y', maxValueMetric + 15) - .text(dayFormat(currentData.time)); - - // Update the text - d3.select(`${prometheusGraphContainer} .text-metric-usage`) - .text(currentData.value.substring(0, 8)); + d3.select(`${currentPrometheusGraphContainer} .text-metric-usage`) + .text(currentMetricValue); + }); } configureGraph() { @@ -263,12 +288,18 @@ class PrometheusGraph { cpu_values: { area_fill_color: '#edf3fc', line_color: '#5b99f7', - graph_legend_title: 'CPU utilization (%)', + graph_legend_title: 'CPU Usage (Cores)', + data: [], + xScale: {}, + yScale: {}, }, memory_values: { area_fill_color: '#fca326', line_color: '#fc6d26', - graph_legend_title: 'Memory usage (MB)', + graph_legend_title: 'Memory Usage (MB)', + data: [], + xScale: {}, + yScale: {}, }, }; @@ -318,17 +349,17 @@ class PrometheusGraph { } transformData(metricsResponse) { - const metricTypes = {}; Object.keys(metricsResponse.metrics).forEach((key) => { if (key === 'cpu_values' || key === 'memory_values') { const metricValues = (metricsResponse.metrics[key])[0]; - metricTypes[key] = metricValues.values.map(metric => ({ - time: new Date(metric[0] * 1000), - value: metric[1], - })); + if (typeof metricValues !== 'undefined') { + this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); + } } }); - this.data = metricTypes; } } diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 3d91e0b22d8..6faa3794c83 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -159,6 +159,16 @@ text { fill: $stat-graph-axis-fill; } + + .label-axis-text, + .text-metric-usage { + fill: $black; + font-weight: 500; + } + + .legend-axis-text { + fill: $black; + } } .x-axis path, diff --git a/app/views/projects/environments/_external_url.html.haml b/app/views/projects/environments/_external_url.html.haml index bf0f1819073..a82ef5ee5bb 100644 --- a/app/views/projects/environments/_external_url.html.haml +++ b/app/views/projects/environments/_external_url.html.haml @@ -1,3 +1,4 @@ - if environment.external_url && can?(current_user, :read_environment, environment) = link_to environment.external_url, target: '_blank', rel: 'noopener noreferrer', class: 'btn external-url' do = icon('external-link') + View deployment diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml index acbac1869fd..e27281d6917 100644 --- a/app/views/projects/environments/_metrics_button.html.haml +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -4,3 +4,4 @@ = link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do = icon('area-chart') + Monitoring diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 3b45162df52..92dc58cd38d 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -11,7 +11,7 @@ .col-sm-6 %h3.page-title Environment: - = @environment.name + = link_to @environment.name, environment_path(@environment) .col-sm-6 .nav-controls diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index f463a429f65..ff6aaebda22 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -4,9 +4,9 @@ %div{ class: container_class } .top-area.adjust - .col-md-9 + .col-md-7 %h3.page-title= @environment.name - .col-md-3 + .col-md-5 .nav-controls = render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment diff --git a/changelogs/unreleased/environment-performance-improvements.yml b/changelogs/unreleased/environment-performance-improvements.yml new file mode 100644 index 00000000000..43e8f0afcee --- /dev/null +++ b/changelogs/unreleased/environment-performance-improvements.yml @@ -0,0 +1,4 @@ +--- +title: Improved UX for the environments metrics view +merge_request: 9946 +author: diff --git a/spec/javascripts/lib/utils/number_utility_spec.js b/spec/javascripts/lib/utils/number_utility_spec.js new file mode 100644 index 00000000000..5fde8be9123 --- /dev/null +++ b/spec/javascripts/lib/utils/number_utility_spec.js @@ -0,0 +1,41 @@ +import { formatRelevantDigits } from '~/lib/utils/number_utils'; + +describe('Number Utils', () => { + describe('formatRelevantDigits', () => { + it('returns an empty string when the number is NaN', () => { + expect(formatRelevantDigits('fail')).toBe(''); + }); + + it('returns 4 decimals when there is 4 plus digits to the left', () => { + const formattedNumber = formatRelevantDigits('1000.1234567'); + const rightFromDecimal = formattedNumber.split('.')[1]; + const leftFromDecimal = formattedNumber.split('.')[0]; + expect(rightFromDecimal.length).toBe(4); + expect(leftFromDecimal.length).toBe(4); + }); + + it('returns 3 decimals when there is 1 digit to the left', () => { + const formattedNumber = formatRelevantDigits('0.1234567'); + const rightFromDecimal = formattedNumber.split('.')[1]; + const leftFromDecimal = formattedNumber.split('.')[0]; + expect(rightFromDecimal.length).toBe(3); + expect(leftFromDecimal.length).toBe(1); + }); + + it('returns 2 decimals when there is 2 digits to the left', () => { + const formattedNumber = formatRelevantDigits('10.1234567'); + const rightFromDecimal = formattedNumber.split('.')[1]; + const leftFromDecimal = formattedNumber.split('.')[0]; + expect(rightFromDecimal.length).toBe(2); + expect(leftFromDecimal.length).toBe(2); + }); + + it('returns 1 decimal when there is 3 digits to the left', () => { + const formattedNumber = formatRelevantDigits('100.1234567'); + const rightFromDecimal = formattedNumber.split('.')[1]; + const leftFromDecimal = formattedNumber.split('.')[0]; + expect(rightFromDecimal.length).toBe(1); + expect(leftFromDecimal.length).toBe(3); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js index a3c1c5e1b7c..c2bcd9c0f7c 100644 --- a/spec/javascripts/monitoring/prometheus_graph_spec.js +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -37,9 +37,11 @@ describe('PrometheusGraph', () => { it('transforms the data', () => { this.prometheusGraph.init(prometheusMockData.metrics); - expect(this.prometheusGraph.data).toBeDefined(); - expect(this.prometheusGraph.data.cpu_values.length).toBe(121); - expect(this.prometheusGraph.data.memory_values.length).toBe(121); + Object.keys(this.prometheusGraph.graphSpecificProperties, (key) => { + const graphProps = this.prometheusGraph.graphSpecificProperties[key]; + expect(graphProps.data).toBeDefined(); + expect(graphProps.data.length).toBe(121); + }); }); it('creates two graphs', () => { @@ -68,7 +70,7 @@ describe('PrometheusGraph', () => { expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined(); expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined(); expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined(); - expect($axisLabelContainer.find('rect').length).toBe(2); + expect($axisLabelContainer.find('rect').length).toBe(3); expect($axisLabelContainer.find('text').length).toBe(4); }); }); |