Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorRémy Coutable <remy@rymai.me>2017-03-07 19:57:44 +0300
committerRémy Coutable <remy@rymai.me>2017-03-07 19:57:44 +0300
commit4998f15169f0d8875676070ee29b5cbca96d582d (patch)
tree6ebbdf0f12c5e0d30a3e806284c361b8764d9ccf /app
parenta5db7f54252d22e3ecd49786a3fdff7c46658fa3 (diff)
parent55f2a5debcf10a4d3ca1d0e53fe06f38bc0b77a9 (diff)
Merge branch 'prometheus-monitoring' into 'master'
Added Prometheus Service and Prometheus graphs See merge request !8935
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/dispatcher.js3
-rw-r--r--app/assets/javascripts/monitoring/prometheus_graph.js333
-rw-r--r--app/assets/stylesheets/pages/environments.scss68
-rw-r--r--app/controllers/projects/environments_controller.rb15
-rw-r--r--app/helpers/gitlab_routing_helper.rb4
-rw-r--r--app/models/environment.rb8
-rw-r--r--app/models/project.rb9
-rw-r--r--app/models/project_services/monitoring_service.rb16
-rw-r--r--app/models/project_services/prometheus_service.rb93
-rw-r--r--app/models/service.rb1
-rw-r--r--app/views/projects/environments/_metrics_button.html.haml6
-rw-r--r--app/views/projects/environments/metrics.html.haml21
-rw-r--r--app/views/projects/environments/show.html.haml1
13 files changed, 577 insertions, 1 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js
index 31f10f89245..4f0c85f4161 100644
--- a/app/assets/javascripts/dispatcher.js
+++ b/app/assets/javascripts/dispatcher.js
@@ -1,3 +1,4 @@
+import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
/* global UsernameValidator */
/* global ActiveTabMemoizer */
@@ -297,6 +298,8 @@ const UserCallout = require('./user_callout');
case 'ci:lints:show':
new gl.CILintEditor();
break;
+ case 'projects:environments:metrics':
+ new PrometheusGraph();
case 'users:show':
new UserCallout();
break;
diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js
new file mode 100644
index 00000000000..9384fe3f276
--- /dev/null
+++ b/app/assets/javascripts/monitoring/prometheus_graph.js
@@ -0,0 +1,333 @@
+/* eslint-disable no-new*/
+import d3 from 'd3';
+import _ from 'underscore';
+import statusCodes from '~/lib/utils/http_status';
+import '~/lib/utils/common_utils';
+import Flash from '~/flash';
+
+const prometheusGraphsContainer = '.prometheus-graph';
+const metricsEndpoint = 'metrics.json';
+const timeFormat = d3.time.format('%H:%M');
+const dayFormat = d3.time.format('%b %e, %a');
+const bisectDate = d3.bisector(d => d.time).left;
+const extraAddedWidthParent = 100;
+
+class PrometheusGraph {
+
+ constructor() {
+ this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
+ this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
+ const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
+ extraAddedWidthParent;
+ this.originalWidth = parentContainerWidth;
+ this.originalHeight = 400;
+ this.width = parentContainerWidth - this.margin.left - this.margin.right;
+ this.height = 400 - this.margin.top - this.margin.bottom;
+ this.backOffRequestCounter = 0;
+ this.configureGraph();
+ this.init();
+ }
+
+ createGraph() {
+ const self = this;
+ _.each(this.data, (value, key) => {
+ if (value.length > 0 && (key === 'cpu_values' || key === 'memory_values')) {
+ self.plotValues(value, key);
+ }
+ });
+ }
+
+ init() {
+ const self = this;
+ this.getData().then((metricsResponse) => {
+ if (metricsResponse === {}) {
+ new Flash('Empty metrics', 'alert');
+ } else {
+ self.transformData(metricsResponse);
+ self.createGraph();
+ }
+ });
+ }
+
+ plotValues(valuesToPlot, 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}]`;
+
+ const graphSpecifics = this.graphSpecificProperties[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})`);
+
+ 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)
+ .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))]);
+
+ const xAxis = d3.svg.axis()
+ .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');
+
+ this.createAxisLabelContainers(axisLabelContainer, key);
+
+ chart.append('g')
+ .attr('class', 'x-axis')
+ .attr('transform', `translate(0,${this.height})`)
+ .call(xAxis);
+
+ chart.append('g')
+ .attr('class', 'y-axis')
+ .call(yAxis);
+
+ const area = d3.svg.area()
+ .x(d => x(d.time))
+ .y0(this.height)
+ .y1(d => y(d.value))
+ .interpolate('linear');
+
+ const line = d3.svg.line()
+ .x(d => x(d.time))
+ .y(d => y(d.value));
+
+ chart.append('path')
+ .datum(valuesToPlot)
+ .attr('d', area)
+ .attr('class', 'metric-area')
+ .attr('fill', graphSpecifics.area_fill_color);
+
+ chart.append('path')
+ .datum(valuesToPlot)
+ .attr('class', 'metric-line')
+ .attr('stroke', graphSpecifics.line_color)
+ .attr('fill', 'none')
+ .attr('stroke-width', this.commonGraphProperties.area_stroke_width)
+ .attr('d', line);
+
+ // Overlay area for the mouseover events
+ chart.append('rect')
+ .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));
+ }
+
+ // The legends from the metric
+ createAxisLabelContainers(axisLabelContainer, key) {
+ 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,
+ });
+
+ 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,
+ });
+
+ 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);
+
+ 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);
+
+ 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');
+
+ // Legends
+
+ // Metric Usage
+ axisLabelContainer.append('rect')
+ .attr('x', this.originalWidth - 170)
+ .attr('y', (this.originalHeight / 2) - 80)
+ .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) - 65)
+ .text(graphSpecifics.graph_legend_title);
+
+ axisLabelContainer.append('text')
+ .attr('class', 'text-metric-usage')
+ .attr('x', this.originalWidth - 140)
+ .attr('y', (this.originalHeight / 2) - 50);
+ }
+
+ handleMouseOverGraph(x, y, valuesToPlot, chart, prometheusGraphContainer, key) {
+ 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,
+ });
+
+ 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));
+ }
+
+ configureGraph() {
+ this.graphSpecificProperties = {
+ cpu_values: {
+ area_fill_color: '#edf3fc',
+ line_color: '#5b99f7',
+ graph_legend_title: 'CPU Usage (Cores)',
+ },
+ memory_values: {
+ area_fill_color: '#fca326',
+ line_color: '#fc6d26',
+ graph_legend_title: 'Memory Usage (MB)',
+ },
+ };
+
+ this.commonGraphProperties = {
+ area_stroke_width: 2,
+ median_total_characters: 8,
+ circle_radius_metric: 5,
+ rect_text_width: 90,
+ rect_text_height: 40,
+ axis_no_ticks: 3,
+ };
+ }
+
+ getData() {
+ const maxNumberOfRequests = 3;
+ return gl.utils.backOff((next, stop) => {
+ $.ajax({
+ url: metricsEndpoint,
+ dataType: 'json',
+ })
+ .done((data, statusText, resp) => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ this.backOffRequestCounter = this.backOffRequestCounter += 1;
+ if (this.backOffRequestCounter < maxNumberOfRequests) {
+ next();
+ } else {
+ stop({
+ status: resp.status,
+ metrics: data,
+ });
+ }
+ } else {
+ stop({
+ status: resp.status,
+ metrics: data,
+ });
+ }
+ }).fail(stop);
+ })
+ .then((resp) => {
+ if (resp.status === statusCodes.NO_CONTENT) {
+ return {};
+ }
+ return resp.metrics;
+ })
+ .catch(() => new Flash('An error occurred while fetching metrics.', 'alert'));
+ }
+
+ transformData(metricsResponse) {
+ const metricTypes = {};
+ _.each(metricsResponse.metrics, (value, key) => {
+ const metricValues = value[0].values;
+ metricTypes[key] = _.map(metricValues, metric => ({
+ time: new Date(metric[0] * 1000),
+ value: metric[1],
+ }));
+ });
+ this.data = metricTypes;
+ }
+}
+
+export default PrometheusGraph;
diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss
index 77e09e66340..0e2b8dba780 100644
--- a/app/assets/stylesheets/pages/environments.scss
+++ b/app/assets/stylesheets/pages/environments.scss
@@ -143,3 +143,71 @@
}
}
}
+
+.prometheus-graph {
+ text {
+ fill: $stat-graph-axis-fill;
+ }
+}
+
+.x-axis path,
+.y-axis path,
+.label-x-axis-line,
+.label-y-axis-line {
+ fill: none;
+ stroke-width: 1;
+ shape-rendering: crispEdges;
+}
+
+.x-axis path,
+.y-axis path {
+ stroke: $stat-graph-axis-fill;
+}
+
+.label-x-axis-line,
+.label-y-axis-line {
+ stroke: $border-color;
+}
+
+.y-axis {
+ line {
+ stroke: $stat-graph-axis-fill;
+ stroke-width: 1;
+ }
+}
+
+.metric-area {
+ opacity: 0.8;
+}
+
+.prometheus-graph-overlay {
+ fill: none;
+ opacity: 0.0;
+ pointer-events: all;
+}
+
+.rect-text-metric {
+ fill: $white-light;
+ stroke-width: 1;
+ stroke: $black;
+}
+
+.rect-axis-text {
+ fill: $white-light;
+}
+
+.text-metric,
+.text-median-metric,
+.text-metric-usage,
+.text-metric-date {
+ fill: $black;
+}
+
+.text-metric-date {
+ font-weight: 200;
+}
+
+.selected-metric-line {
+ stroke: $black;
+ stroke-width: 1;
+}
diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb
index fed75396d6e..fa37963dfd4 100644
--- a/app/controllers/projects/environments_controller.rb
+++ b/app/controllers/projects/environments_controller.rb
@@ -5,7 +5,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_create_deployment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
- before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize]
+ before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics]
before_action :verify_api_request!, only: :terminal_websocket_authorize
def index
@@ -109,6 +109,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
+ def metrics
+ # Currently, this acts as a hint to load the metrics details into the cache
+ # if they aren't there already
+ @metrics = environment.metrics || {}
+
+ respond_to do |format|
+ format.html
+ format.json do
+ render json: @metrics, status: @metrics.any? ? :ok : :no_content
+ end
+ end
+ end
+
private
def verify_api_request!
diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb
index f16a63e2178..e9b7cbbad6a 100644
--- a/app/helpers/gitlab_routing_helper.rb
+++ b/app/helpers/gitlab_routing_helper.rb
@@ -74,6 +74,10 @@ module GitlabRoutingHelper
namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
end
+ def environment_metrics_path(environment, *args)
+ metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
+ end
+
def issue_path(entity, *args)
namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args)
end
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 1a21b5e52b5..bf33010fd21 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -145,6 +145,14 @@ class Environment < ActiveRecord::Base
project.deployment_service.terminals(self) if has_terminals?
end
+ def has_metrics?
+ project.monitoring_service.present? && available? && last_deployment.present?
+ end
+
+ def metrics
+ project.monitoring_service.metrics(self) if has_metrics?
+ end
+
# An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has
# the following properties:
diff --git a/app/models/project.rb b/app/models/project.rb
index 90e40c6fa8d..8c2dadf4659 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -113,6 +113,7 @@ class Project < ActiveRecord::Base
has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
has_one :external_wiki_service, dependent: :destroy
has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
+ has_one :prometheus_service, dependent: :destroy, inverse_of: :project
has_one :mock_ci_service, dependent: :destroy
has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id"
@@ -771,6 +772,14 @@ class Project < ActiveRecord::Base
@deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
end
+ def monitoring_services
+ services.where(category: :monitoring)
+ end
+
+ def monitoring_service
+ @monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true)
+ end
+
def jira_tracker?
issues_tracker.to_param == 'jira'
end
diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb
new file mode 100644
index 00000000000..ea585721e8f
--- /dev/null
+++ b/app/models/project_services/monitoring_service.rb
@@ -0,0 +1,16 @@
+# Base class for monitoring services
+#
+# These services integrate with a deployment solution like Prometheus
+# to provide additional features for environments.
+class MonitoringService < Service
+ default_value_for :category, 'monitoring'
+
+ def self.supported_events
+ %w()
+ end
+
+ # Environments have a number of metrics
+ def metrics(environment)
+ raise NotImplementedError
+ end
+end
diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb
new file mode 100644
index 00000000000..375966b9efc
--- /dev/null
+++ b/app/models/project_services/prometheus_service.rb
@@ -0,0 +1,93 @@
+class PrometheusService < MonitoringService
+ include ReactiveCaching
+
+ self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
+ self.reactive_cache_lease_timeout = 30.seconds
+ self.reactive_cache_refresh_interval = 30.seconds
+ self.reactive_cache_lifetime = 1.minute
+
+ # Access to prometheus is directly through the API
+ prop_accessor :api_url
+
+ with_options presence: true, if: :activated? do
+ validates :api_url, url: true
+ end
+
+ after_save :clear_reactive_cache!
+
+ def initialize_properties
+ if properties.nil?
+ self.properties = {}
+ end
+ end
+
+ def title
+ 'Prometheus'
+ end
+
+ def description
+ 'Prometheus monitoring'
+ end
+
+ def help
+ 'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.'
+ end
+
+ def self.to_param
+ 'prometheus'
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'api_url',
+ title: 'API URL',
+ placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/'
+ }
+ ]
+ end
+
+ # Check we can connect to the Prometheus API
+ def test(*args)
+ client.ping
+
+ { success: true, result: 'Checked API endpoint' }
+ rescue Gitlab::PrometheusError => err
+ { success: false, result: err }
+ end
+
+ def metrics(environment)
+ with_reactive_cache(environment.slug) do |data|
+ data
+ end
+ end
+
+ # Cache metrics for specific environment
+ def calculate_reactive_cache(environment_slug)
+ return unless active? && project && !project.pending_delete?
+
+ memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024}
+ cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))}
+
+ {
+ success: true,
+ metrics: {
+ # Memory used in MB
+ memory_values: client.query_range(memory_query, start: 8.hours.ago),
+ memory_current: client.query(memory_query),
+ # CPU Usage rate in cores.
+ cpu_values: client.query_range(cpu_query, start: 8.hours.ago),
+ cpu_current: client.query(cpu_query)
+ },
+ last_update: Time.now.utc
+ }
+
+ rescue Gitlab::PrometheusError => err
+ { success: false, result: err.message }
+ end
+
+ def client
+ @prometheus ||= Gitlab::Prometheus.new(api_url: api_url)
+ end
+end
diff --git a/app/models/service.rb b/app/models/service.rb
index 3ef4cbead10..2f75a2e4e7f 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -232,6 +232,7 @@ class Service < ActiveRecord::Base
mattermost
pipelines_email
pivotaltracker
+ prometheus
pushover
redmine
slack_slash_commands
diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml
new file mode 100644
index 00000000000..acbac1869fd
--- /dev/null
+++ b/app/views/projects/environments/_metrics_button.html.haml
@@ -0,0 +1,6 @@
+- environment = local_assigns.fetch(:environment)
+
+- return unless environment.has_metrics? && can?(current_user, :read_environment, environment)
+
+= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
+ = icon('area-chart')
diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml
new file mode 100644
index 00000000000..f8e94ca98ae
--- /dev/null
+++ b/app/views/projects/environments/metrics.html.haml
@@ -0,0 +1,21 @@
+- @no_container = true
+- page_title "Metrics for environment", @environment.name
+= render "projects/pipelines/head"
+
+%div{ class: container_class }
+ .top-area
+ .row
+ .col-sm-6
+ %h3.page-title
+ Environment:
+ = @environment.name
+
+ .col-sm-6
+ .nav-controls
+ = render 'projects/deployments/actions', deployment: @environment.last_deployment
+ .row
+ .col-sm-12
+ %svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
+ .row
+ .col-sm-12
+ %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml
index 7036325fff8..29a98f23b88 100644
--- a/app/views/projects/environments/show.html.haml
+++ b/app/views/projects/environments/show.html.haml
@@ -8,6 +8,7 @@
%h3.page-title= @environment.name
.col-md-3
.nav-controls
+ = render 'projects/environments/metrics_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
- if can?(current_user, :update_environment, @environment)