diff --git a/app/assets/javascripts/clusters/components/applications.vue b/app/assets/javascripts/clusters/components/applications.vue index 1325a2682143fca2400f914eca455e1d11f63401..27136c7289fa8f97557f2f26c1ea2dd9f9003d40 100644 --- a/app/assets/javascripts/clusters/components/applications.vue +++ b/app/assets/javascripts/clusters/components/applications.vue @@ -117,7 +117,10 @@ diff --git a/app/assets/javascripts/monitoring/components/graph_group.vue b/app/assets/javascripts/monitoring/components/graph_group.vue index 079351a69af6ea20799c2afc13433f99bf45872e..f71cf614552df01047b65830a1388db8b7befe08 100644 --- a/app/assets/javascripts/monitoring/components/graph_group.vue +++ b/app/assets/javascripts/monitoring/components/graph_group.vue @@ -5,12 +5,20 @@ type: String, required: true, }, + showPanels: { + type: Boolean, + required: false, + default: true, + }, }, }; diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js index c3b0ef7e9ca1ff1258c9ab1a6a84719e22c8dd29..41270e015d49390e96af8c18974e382207999419 100644 --- a/app/assets/javascripts/monitoring/monitoring_bundle.js +++ b/app/assets/javascripts/monitoring/monitoring_bundle.js @@ -1,7 +1,22 @@ import Vue from 'vue'; +import { convertPermissionToBoolean } from '~/lib/utils/common_utils'; import Dashboard from './components/dashboard.vue'; -export default () => new Vue({ - el: '#prometheus-graphs', - render: createElement => createElement(Dashboard), -}); +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(Dashboard, { + props: { + ...el.dataset, + hasMetrics: convertPermissionToBoolean(el.dataset.hasMetrics), + }, + }); + }, + }); + } +}; diff --git a/app/assets/javascripts/monitoring/services/monitoring_service.js b/app/assets/javascripts/monitoring/services/monitoring_service.js index e230a06cd8c540f988af33ad51499227d9e23f89..6fcca36d2fad2d77228f5f70c491b4d459d11182 100644 --- a/app/assets/javascripts/monitoring/services/monitoring_service.js +++ b/app/assets/javascripts/monitoring/services/monitoring_service.js @@ -40,6 +40,9 @@ export default class MonitoringService { } getDeploymentData() { + if (!this.deploymentEndpoint) { + return Promise.resolve([]); + } return backOffRequest(() => axios.get(this.deploymentEndpoint)) .then(resp => resp.data) .then((response) => { diff --git a/app/assets/javascripts/pages/projects/environments/index.js b/app/assets/javascripts/pages/projects/environments/index/index.js similarity index 100% rename from app/assets/javascripts/pages/projects/environments/index.js rename to app/assets/javascripts/pages/projects/environments/index/index.js diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 3952af434e19c1b1e671e40c1b9f4175e517aeb9..ed3da2cd3d1177f49b2fbad4fc5eb77149a5f39c 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -529,7 +529,8 @@ } > text { - font-size: 12px; + fill: $theme-gray-600; + font-size: 10px; } } @@ -573,3 +574,17 @@ } } } + +// EE-only +.cluster-health-graphs { + .prometheus-state { + .state-svg img { + max-height: 120px; + } + + .state-description, + .state-button { + display: none; + } + } +} diff --git a/app/controllers/projects/clusters_controller.rb b/app/controllers/projects/clusters_controller.rb index d58039b7d42cba0bb8fc47e95a1fa811f1f429b3..e15d4aac318bde62f98b3a0e1523d1137b796a76 100644 --- a/app/controllers/projects/clusters_controller.rb +++ b/app/controllers/projects/clusters_controller.rb @@ -64,6 +64,22 @@ def destroy end end + def metrics + return render_404 unless prometheus_adapter&.can_query? + + respond_to do |format| + format.json do + metrics = prometheus_adapter.query(:cluster) || {} + + if metrics.any? + render json: metrics + else + head :no_content + end + end + end + end + private def cluster @@ -71,6 +87,12 @@ def cluster .present(current_user: current_user) end + def prometheus_adapter + return unless cluster&.application_prometheus&.installed? + + cluster.application_prometheus + end + def update_params if cluster.managed? params.require(:cluster).permit( diff --git a/app/views/projects/clusters/show.html.haml b/app/views/projects/clusters/show.html.haml index 2ee0eafcf1af341df963ac2ce9360881d331a798..04acb979b49dff6d5b335e04851044376f1f9375 100644 --- a/app/views/projects/clusters/show.html.haml +++ b/app/views/projects/clusters/show.html.haml @@ -22,6 +22,10 @@ .js-cluster-application-notice .flash-container + -# EE-specific + - if @cluster.project.feature_available?(:cluster_health) + = render 'health' + %section.settings.no-animate.expanded#cluster-integration = render 'banner' = render 'integration_form' diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml index 9d9759ebc5fb140d27cccba33c431aedeb9cf03e..c151b5acdf7256d3c221f384e5545fbeba3761f7 100644 --- a/app/views/projects/environments/metrics.html.haml +++ b/app/views/projects/environments/metrics.html.haml @@ -15,7 +15,8 @@ "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), - "additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json), + "metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json), + "deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json), "project-path": project_path(@project), "tags-path": project_tags_path(@project), - "has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } } + "has-metrics": "#{@environment.has_metrics?}" } } diff --git a/config/prometheus/cluster_metrics.yml b/config/prometheus/cluster_metrics.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb4735f8856f8d4f35efa6765e2b933d7cfaaf07 --- /dev/null +++ b/config/prometheus/cluster_metrics.yml @@ -0,0 +1,25 @@ +- group: Cluster Health + priority: 1 + metrics: + - title: "CPU Usage" + y_label: "CPU" + required_metrics: ['container_cpu_usage_seconds_total'] + weight: 1 + queries: + - query_range: 'avg(sum(rate(container_cpu_usage_seconds_total{id="/"}[15m])) by (job)) without (job)' + label: Usage + unit: "cores" + - query_range: 'sum(kube_node_status_capacity_cpu_cores{kubernetes_namespace="gitlab-managed-apps"})' + label: Capacity + unit: "cores" + - title: "Memory usage" + y_label: "Memory" + required_metrics: ['container_memory_usage_bytes'] + weight: 1 + queries: + - query_range: 'avg(sum(container_memory_usage_bytes{id="/"}) by (job)) without (job) / 2^30' + label: Usage + unit: "GiB" + - query_range: 'sum(kube_node_status_capacity_memory_bytes{kubernetes_namespace="gitlab-managed-apps"})/2^30' + label: Capacity + unit: "GiB" \ No newline at end of file diff --git a/config/routes/project.rb b/config/routes/project.rb index 5820ccfa342b8826bf865314c6a5ad23ade376ff..c8e0a09570ac3c33c3b987d07bb6d62956ccf837 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -244,6 +244,7 @@ member do get :status, format: :json + get :metrics, format: :json scope :applications do post '/:application', to: 'clusters/applications#create', as: :install_applications diff --git a/ee/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js b/ee/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js new file mode 100644 index 0000000000000000000000000000000000000000..8afe1aabdcf0e8338bcb1899b5951d2ff1a74ecd --- /dev/null +++ b/ee/app/assets/javascripts/pages/projects/clusters/show/cluster_health.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Dashboard from '~/monitoring/components/dashboard.vue'; + +export default () => { + const el = document.getElementById('prometheus-graphs'); + + if (el && el.dataset) { + // eslint-disable-next-line no-new + new Vue({ + el, + render(createElement) { + return createElement(Dashboard, { + props: { + ...el.dataset, + showLegend: false, + showPanels: false, + forceSmallGraph: true, + }, + }); + }, + }); + } +}; diff --git a/ee/app/assets/javascripts/pages/projects/clusters/show/index.js b/ee/app/assets/javascripts/pages/projects/clusters/show/index.js new file mode 100644 index 0000000000000000000000000000000000000000..873c18c8ab0b087650e3fbc1cda8b163c85b56c7 --- /dev/null +++ b/ee/app/assets/javascripts/pages/projects/clusters/show/index.js @@ -0,0 +1,4 @@ +import '~/pages/projects/clusters/show'; +import initClusterHealth from './cluster_health'; + +document.addEventListener('DOMContentLoaded', initClusterHealth); diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index 721ea3d48946327ed8a88e4ac4f131cb2c8a7a19..04b95f178f3c362aaa6a85ef04653de3d6f02a6f 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -60,6 +60,7 @@ class License < ActiveRecord::Base EEU_FEATURES = EEP_FEATURES + %i[ sast sast_container + cluster_health dast epics ide diff --git a/ee/app/views/projects/clusters/_health.html.haml b/ee/app/views/projects/clusters/_health.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..3416fd9f227b4071d9591476985accb31a4d564b --- /dev/null +++ b/ee/app/views/projects/clusters/_health.html.haml @@ -0,0 +1,21 @@ + +%section.settings.no-animate.expanded.cluster-health-graphs#cluster-health + %h4= s_('ClusterIntegration|Kubernetes cluster health') + + - if @cluster&.application_prometheus&.installed? + #prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'), + "clusters-path": project_clusters_path(@project), + "documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), + "empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), + "empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), + "empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), + "metrics-endpoint": metrics_namespace_project_cluster_path( format: :json ), + "project-path": project_path(@project), + "tags-path": project_tags_path(@project) } } + + - else + .settings-content + %p= s_("ClusterIntegration|In order to show the health of the cluster, we'll need to provision your cluster with Prometheus to collect the required data.") + + %a.btn.btn-default{ href: '#cluster-applications' } + = s_('ClusterIntegration|Install Prometheus') diff --git a/ee/changelogs/unreleased/5029-support-cluster-metrics.yml b/ee/changelogs/unreleased/5029-support-cluster-metrics.yml new file mode 100644 index 0000000000000000000000000000000000000000..d07ead28befc610822cc467d908b10385a6fea5b --- /dev/null +++ b/ee/changelogs/unreleased/5029-support-cluster-metrics.yml @@ -0,0 +1,5 @@ +--- +title: Query cluster status +merge_request: 4701 +author: +type: added diff --git a/ee/lib/gitlab/prometheus/queries/cluster_query.rb b/ee/lib/gitlab/prometheus/queries/cluster_query.rb new file mode 100644 index 0000000000000000000000000000000000000000..1eff80db14763eeb565c76022ade69fce0cdc422 --- /dev/null +++ b/ee/lib/gitlab/prometheus/queries/cluster_query.rb @@ -0,0 +1,14 @@ +module Gitlab + module Prometheus + module Queries + class ClusterQuery < BaseQuery + include QueryAdditionalMetrics + + def query + AdditionalMetricsParser.load_groups_from_yaml('cluster_metrics.yml') + .map(&query_group(base_query_context(8.hours.ago, Time.now))) + end + end + end + end +end diff --git a/ee/spec/lib/gitlab/prometheus/queries/cluster_query_spec.rb b/ee/spec/lib/gitlab/prometheus/queries/cluster_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..44ef5ac03dbfd1b76cb0e7c25ec1286fc700908b --- /dev/null +++ b/ee/spec/lib/gitlab/prometheus/queries/cluster_query_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Gitlab::Prometheus::Queries::ClusterQuery do + let(:client) { double('prometheus_client', query_range: nil) } + subject { described_class.new(client) } + + around do |example| + Timecop.freeze { example.run } + end + + it 'load cluster metrics from yaml' do + expect(Gitlab::Prometheus::AdditionalMetricsParser).to receive(:load_groups_from_yaml).with('cluster_metrics.yml').and_call_original + + subject.query + end + + it 'sends queries to prometheus' do + subject.query + + expect(client).to have_received(:query_range).with(anything, start: 8.hours.ago, stop: Time.now).at_least(1) + end +end diff --git a/spec/controllers/projects/clusters_controller_spec.rb b/spec/controllers/projects/clusters_controller_spec.rb index 15ce418d0d6e4a93c5abfd173af407adbf342cad..edd58bc38e122b494a17120f93309b6d5ef82e81 100644 --- a/spec/controllers/projects/clusters_controller_spec.rb +++ b/spec/controllers/projects/clusters_controller_spec.rb @@ -155,6 +155,93 @@ def go end end + describe 'GET metrics' do + let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } + + describe 'functionality' do + let(:user) { create(:user) } + + before do + project.add_master(user) + sign_in(user) + end + + context "Can't query Prometheus" do + it 'returns not found' do + go + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'can query Prometheus' do + let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true, query: nil) } + + before do + allow(controller).to receive(:prometheus_adapter).and_return(prometheus_adapter) + end + + it 'queries cluster metrics' do + go + + expect(prometheus_adapter).to have_received(:query).with(:cluster) + end + + context 'when response has content' do + let(:query_response) { { response: nil } } + + before do + allow(prometheus_adapter).to receive(:query).and_return(query_response) + end + + it 'returns prometheus query response' do + go + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to eq(query_response.to_json) + end + end + + context 'when response has no content' do + let(:query_response) { {} } + + before do + allow(prometheus_adapter).to receive(:query).and_return(query_response) + end + + it 'returns prometheus query response' do + go + + expect(response).to have_gitlab_http_status(:no_content) + end + end + end + end + + def go + get :metrics, format: :json, + namespace_id: project.namespace, + project_id: project, + id: cluster + end + + describe 'security' do + let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true, query: nil) } + before do + allow(controller).to receive(:prometheus_adapter).and_return(prometheus_adapter) + end + + it { expect { go }.to be_allowed_for(:admin) } + it { expect { go }.to be_allowed_for(:owner).of(project) } + it { expect { go }.to be_allowed_for(:master).of(project) } + it { expect { go }.to be_denied_for(:developer).of(project) } + it { expect { go }.to be_denied_for(:reporter).of(project) } + it { expect { go }.to be_denied_for(:guest).of(project) } + it { expect { go }.to be_denied_for(:user) } + it { expect { go }.to be_denied_for(:external) } + end + end + describe 'PUT update' do context 'when cluster is provided by GCP' do let(:cluster) { create(:cluster, :provided_by_gcp, projects: [project]) } diff --git a/spec/controllers/projects/prometheus/metrics_controller_spec.rb b/spec/controllers/projects/prometheus/metrics_controller_spec.rb index b2b245dba90eb80a3abb7afd7b9ea8611e858afb..5725aa6a78d229b5dd0694294af9fe9e336cfb99 100644 --- a/spec/controllers/projects/prometheus/metrics_controller_spec.rb +++ b/spec/controllers/projects/prometheus/metrics_controller_spec.rb @@ -7,6 +7,8 @@ let(:prometheus_adapter) { double('prometheus_adapter', can_query?: true) } before do + allow(controller).to receive(:project).and_return(project) + project.add_master(user) sign_in(user) end diff --git a/spec/javascripts/fixtures/environments.rb b/spec/javascripts/fixtures/environments.rb deleted file mode 100644 index d2457d75419b61f0ce2ce79ae5b7d1d5c038f9f3..0000000000000000000000000000000000000000 --- a/spec/javascripts/fixtures/environments.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'spec_helper' - -describe Projects::EnvironmentsController, '(JavaScript fixtures)', type: :controller do - include JavaScriptFixturesHelpers - - let(:admin) { create(:admin) } - let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} - let(:project) { create(:project_empty_repo, namespace: namespace, path: 'environments-project') } - let(:environment) { create(:environment, name: 'production', project: project) } - - render_views - - before(:all) do - clean_frontend_fixtures('environments/metrics') - end - - before do - sign_in(admin) - end - - it 'environments/metrics/metrics.html.raw' do |example| - get :metrics, - namespace_id: project.namespace, - project_id: project, - id: environment.id - - expect(response).to be_success - store_frontend_fixture(response, example.description) - end -end diff --git a/spec/javascripts/monitoring/dashboard_spec.js b/spec/javascripts/monitoring/dashboard_spec.js index eb8f6bbe50d286043ae6e623d5087d85e3ebf99d..29b355307ef04f6f39374b9a2f7504d8745a5b4c 100644 --- a/spec/javascripts/monitoring/dashboard_spec.js +++ b/spec/javascripts/monitoring/dashboard_spec.js @@ -5,24 +5,35 @@ import axios from '~/lib/utils/axios_utils'; import { metricsGroupsAPIResponse, mockApiEndpoint } from './mock_data'; describe('Dashboard', () => { - const fixtureName = 'environments/metrics/metrics.html.raw'; let DashboardComponent; - let component; - preloadFixtures(fixtureName); + + const propsData = { + hasMetrics: false, + documentationPath: '/path/to/docs', + settingsPath: '/path/to/settings', + clustersPath: '/path/to/clusters', + tagsPath: '/path/to/tags', + projectPath: '/path/to/project', + metricsEndpoint: mockApiEndpoint, + deploymentEndpoint: null, + emptyGettingStartedSvgPath: '/path/to/getting-started.svg', + emptyLoadingSvgPath: '/path/to/loading.svg', + emptyUnableToConnectSvgPath: '/path/to/unable-to-connect.svg', + }; beforeEach(() => { - loadFixtures(fixtureName); + setFixtures('
'); DashboardComponent = Vue.extend(Dashboard); }); describe('no metrics are available yet', () => { it('shows a getting started empty state when no metrics are present', () => { - component = new DashboardComponent({ - el: document.querySelector('#prometheus-graphs'), + const component = new DashboardComponent({ + el: document.querySelector('.prometheus-graphs'), + propsData, }); - component.$mount(); - expect(component.$el.querySelector('#prometheus-graphs')).toBe(null); + expect(component.$el.querySelector('.prometheus-graphs')).toBe(null); expect(component.state).toEqual('gettingStarted'); }); }); @@ -30,11 +41,8 @@ describe('Dashboard', () => { describe('requests information to the server', () => { let mock; beforeEach(() => { - document.querySelector('#prometheus-graphs').setAttribute('data-has-metrics', 'true'); mock = new MockAdapter(axios); - mock.onGet(mockApiEndpoint).reply(200, { - metricsGroupsAPIResponse, - }); + mock.onGet(mockApiEndpoint).reply(200, metricsGroupsAPIResponse); }); afterEach(() => { @@ -42,14 +50,43 @@ describe('Dashboard', () => { }); it('shows up a loading state', (done) => { - component = new DashboardComponent({ - el: document.querySelector('#prometheus-graphs'), + const component = new DashboardComponent({ + el: document.querySelector('.prometheus-graphs'), + propsData: { ...propsData, hasMetrics: true }, }); - component.$mount(); + Vue.nextTick(() => { expect(component.state).toEqual('loading'); done(); }); }); + + it('hides the legend when showLegend is false', (done) => { + const component = new DashboardComponent({ + el: document.querySelector('.prometheus-graphs'), + propsData: { ...propsData, hasMetrics: true, showLegend: false }, + }); + + setTimeout(() => { + expect(component.showEmptyState).toEqual(false); + expect(component.$el.querySelector('.legend-group')).toEqual(null); + expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); + done(); + }); + }); + + it('hides the group panels when showPanels is false', (done) => { + const component = new DashboardComponent({ + el: document.querySelector('.prometheus-graphs'), + propsData: { ...propsData, hasMetrics: true, showPanels: false }, + }); + + setTimeout(() => { + expect(component.showEmptyState).toEqual(false); + expect(component.$el.querySelector('.prometheus-panel')).toEqual(null); + expect(component.$el.querySelector('.prometheus-graph-group')).toBeTruthy(); + done(); + }); + }); }); }); diff --git a/spec/models/concerns/prometheus_adapter_spec.rb b/spec/models/concerns/prometheus_adapter_spec.rb index f4b9c57e71a3e76ed360d65b98b7914b728981df..10c3ea634d76575903431404a2f672f2dfd66add 100644 --- a/spec/models/concerns/prometheus_adapter_spec.rb +++ b/spec/models/concerns/prometheus_adapter_spec.rb @@ -4,14 +4,15 @@ include PrometheusHelpers include ReactiveCachingHelpers - class TestClass - include PrometheusAdapter - end - let(:project) { create(:prometheus_project) } let(:service) { project.prometheus_service } - let(:described_class) { TestClass } + let(:described_class) do + Class.new do + include PrometheusAdapter + end + end + let(:environment_query) { Gitlab::Prometheus::Queries::EnvironmentQuery } describe '#query' do diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index c8af359eebd85a4c86a21b43ca87c23c040875df..eb86ddc257f56328e4fe7917470baeaecf6a2e27 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -547,7 +547,7 @@ let(:project) { create(:prometheus_project) } subject { environment.additional_metrics } - context 'when the environment has additional metrics' do + context 'when the environment has metrics' do before do allow(environment).to receive(:has_metrics?).and_return(true) end