From fba991dc482f726f7f5afd0ef4facbc1552e8d3c Mon Sep 17 00:00:00 2001
From: Simon Knox <psimyn@gmail.com>
Date: Thu, 6 Jun 2019 08:03:30 +1000
Subject: [PATCH] Add feature flag and dashboard endpoint

First part of FE for Prometheus API
Dashboard endpoint fetches all info except for chart results
Renders empty groups after loading
---
 .../javascripts/lib/utils/invalid_url.js      |  6 ++
 .../monitoring/components/dashboard.vue       |  7 ++
 .../monitoring/monitoring_bundle.js           |  5 ++
 .../javascripts/monitoring/stores/actions.js  | 33 +++++++
 .../monitoring/stores/mutation_types.js       |  1 +
 .../monitoring/stores/mutations.js            | 26 +++++-
 .../javascripts/monitoring/stores/state.js    |  2 +
 .../javascripts/monitoring/stores/utils.js    |  6 +-
 app/helpers/environments_helper.rb            |  1 +
 spec/javascripts/monitoring/mock_data.js      | 64 +++++++++++++
 .../monitoring/store/actions_spec.js          | 89 ++++++++++++++++++-
 .../monitoring/store/mutations_spec.js        | 39 ++++++--
 12 files changed, 266 insertions(+), 13 deletions(-)
 create mode 100644 app/assets/javascripts/lib/utils/invalid_url.js

diff --git a/app/assets/javascripts/lib/utils/invalid_url.js b/app/assets/javascripts/lib/utils/invalid_url.js
new file mode 100644
index 00000000000..481bd059fc9
--- /dev/null
+++ b/app/assets/javascripts/lib/utils/invalid_url.js
@@ -0,0 +1,6 @@
+/**
+ * Invalid URL that ensures we don't make a network request
+ * Can be used as a default value for URLs. Using an empty
+ * string can still result in request being made to the current page
+ */
+export default 'https://invalid';
diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue
index 78744c0a0a9..b5675d7bf99 100644
--- a/app/assets/javascripts/monitoring/components/dashboard.vue
+++ b/app/assets/javascripts/monitoring/components/dashboard.vue
@@ -13,6 +13,7 @@ import { s__ } from '~/locale';
 import Icon from '~/vue_shared/components/icon.vue';
 import '~/vue_shared/mixins/is_ee';
 import { getParameterValues } from '~/lib/utils/url_utility';
+import invalidUrl from '~/lib/utils/invalid_url';
 import MonitorAreaChart from './charts/area.vue';
 import GraphGroup from './graph_group.vue';
 import EmptyState from './empty_state.vue';
@@ -123,6 +124,11 @@ export default {
       type: String,
       required: true,
     },
+    dashboardEndpoint: {
+      type: String,
+      required: false,
+      default: invalidUrl,
+    },
   },
   data() {
     return {
@@ -150,6 +156,7 @@ export default {
       metricsEndpoint: this.metricsEndpoint,
       environmentsEndpoint: this.environmentsEndpoint,
       deploymentsEndpoint: this.deploymentEndpoint,
+      dashboardEndpoint: this.dashboardEndpoint,
     });
 
     this.timeWindows = timeWindows;
diff --git a/app/assets/javascripts/monitoring/monitoring_bundle.js b/app/assets/javascripts/monitoring/monitoring_bundle.js
index 57771ccf4d9..751b6ff6b10 100644
--- a/app/assets/javascripts/monitoring/monitoring_bundle.js
+++ b/app/assets/javascripts/monitoring/monitoring_bundle.js
@@ -7,6 +7,11 @@ export default (props = {}) => {
   const el = document.getElementById('prometheus-graphs');
 
   if (el && el.dataset) {
+    store.dispatch(
+      'monitoringDashboard/setDashboardEnabled',
+      gon.features.environmentMetricsUsePrometheusEndpoint,
+    );
+
     // eslint-disable-next-line no-new
     new Vue({
       el,
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js
index 63c23e8449d..b336f12eab2 100644
--- a/app/assets/javascripts/monitoring/stores/actions.js
+++ b/app/assets/javascripts/monitoring/stores/actions.js
@@ -35,6 +35,20 @@ export const setEndpoints = ({ commit }, endpoints) => {
   commit(types.SET_ENDPOINTS, endpoints);
 };
 
+export const setDashboardEnabled = ({ commit }, enabled) => {
+  commit(types.SET_DASHBOARD_ENABLED, enabled);
+};
+
+export const requestMetricsDashboard = ({ commit }) => {
+  commit(types.REQUEST_METRICS_DATA);
+};
+export const receiveMetricsDashboardSuccess = ({ commit }, { response }) => {
+  commit(types.RECEIVE_METRICS_DATA_SUCCESS, response.dashboard.panel_groups);
+};
+export const receiveMetricsDashboardFailure = ({ commit }, error) => {
+  commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
+};
+
 export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
 export const receiveMetricsDataSuccess = ({ commit }, data) =>
   commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
@@ -56,6 +70,10 @@ export const fetchData = ({ dispatch }, params) => {
 };
 
 export const fetchMetricsData = ({ state, dispatch }, params) => {
+  if (state.useDashboardEndpoint) {
+    return dispatch('fetchDashboard', params);
+  }
+
   dispatch('requestMetricsData');
 
   return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
@@ -73,6 +91,21 @@ export const fetchMetricsData = ({ state, dispatch }, params) => {
     });
 };
 
+export const fetchDashboard = ({ state, dispatch }, params) => {
+  dispatch('requestMetricsDashboard');
+
+  return axios
+    .get(state.dashboardEndpoint, { params })
+    .then(resp => resp.data)
+    .then(response => {
+      dispatch('receiveMetricsDashboardSuccess', { response });
+    })
+    .catch(error => {
+      dispatch('receiveMetricsDashboardFailure', error);
+      createFlash(s__('Metrics|There was an error while retrieving metrics'));
+    });
+};
+
 export const fetchDeploymentsData = ({ state, dispatch }) => {
   if (!state.deploymentEndpoint) {
     return Promise.resolve([]);
diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js
index 3fd9e07fa8b..09fdc0b5b05 100644
--- a/app/assets/javascripts/monitoring/stores/mutation_types.js
+++ b/app/assets/javascripts/monitoring/stores/mutation_types.js
@@ -8,5 +8,6 @@ export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
 export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
 export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
 export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
+export const SET_DASHBOARD_ENABLED = 'SET_DASHBOARD_ENABLED';
 export const SET_ENDPOINTS = 'SET_ENDPOINTS';
 export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js
index c1779333d75..c2b40472b0a 100644
--- a/app/assets/javascripts/monitoring/stores/mutations.js
+++ b/app/assets/javascripts/monitoring/stores/mutations.js
@@ -7,10 +7,24 @@ export default {
     state.showEmptyState = true;
   },
   [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
-    state.groups = groupData.map(group => ({
-      ...group,
-      metrics: normalizeMetrics(sortMetrics(group.metrics)),
-    }));
+    state.groups = groupData.map(group => {
+      let { metrics } = group;
+
+      // 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 = group.panels.map(panel => ({
+          ...panel,
+          queries: panel.metrics,
+        }));
+      }
+
+      return {
+        ...group,
+        metrics: normalizeMetrics(sortMetrics(metrics)),
+      };
+    });
 
     if (!state.groups.length) {
       state.emptyState = 'noData';
@@ -38,6 +52,10 @@ export default {
     state.metricsEndpoint = endpoints.metricsEndpoint;
     state.environmentsEndpoint = endpoints.environmentsEndpoint;
     state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
+    state.dashboardEndpoint = endpoints.dashboardEndpoint;
+  },
+  [types.SET_DASHBOARD_ENABLED](state, enabled) {
+    state.useDashboardEndpoint = enabled;
   },
   [types.SET_GETTING_STARTED_EMPTY_STATE](state) {
     state.emptyState = 'gettingStarted';
diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js
index 5103122612a..b3649a3852b 100644
--- a/app/assets/javascripts/monitoring/stores/state.js
+++ b/app/assets/javascripts/monitoring/stores/state.js
@@ -4,6 +4,8 @@ export default () => ({
   metricsEndpoint: null,
   environmentsEndpoint: null,
   deploymentsEndpoint: null,
+  dashboardEndpoint: null,
+  useDashboardEndpoint: false,
   emptyState: 'gettingStarted',
   showEmptyState: true,
   groups: [],
diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js
index 9216554ecbf..10537600240 100644
--- a/app/assets/javascripts/monitoring/stores/utils.js
+++ b/app/assets/javascripts/monitoring/stores/utils.js
@@ -66,9 +66,9 @@ export const normalizeMetrics = metrics => {
       ...query,
       // custom metrics do not require a label, so we should ensure this attribute is defined
       label: query.label || metric.y_label,
-      result: query.result.map(result => ({
-        ...result,
-        values: result.values.map(([timestamp, value]) => [
+      result: (query.result || []).map(timeSeries => ({
+        ...timeSeries,
+        values: timeSeries.values.map(([timestamp, value]) => [
           new Date(timestamp * 1000).toISOString(),
           Number(value),
         ]),
diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb
index 8002eb08ada..855b243cc8a 100644
--- a/app/helpers/environments_helper.rb
+++ b/app/helpers/environments_helper.rb
@@ -26,6 +26,7 @@ module EnvironmentsHelper
       "empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'),
       "empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
       "metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
+      "dashboard-endpoint" => metrics_dashboard_project_environment_path(project, environment, format: :json),
       "deployment-endpoint" => project_environment_deployments_path(project, environment, format: :json),
       "environments-endpoint": project_environments_path(project, format: :json),
       "project-path" => project_path(project),
diff --git a/spec/javascripts/monitoring/mock_data.js b/spec/javascripts/monitoring/mock_data.js
index d9d8cb66749..9b429be69f7 100644
--- a/spec/javascripts/monitoring/mock_data.js
+++ b/spec/javascripts/monitoring/mock_data.js
@@ -857,3 +857,67 @@ export const environmentData = [
     updated_at: '2018-07-04T18:44:54.010Z',
   },
 ];
+
+export const metricsDashboardResponse = {
+  dashboard: {
+    dashboard: 'Environment metrics',
+    priority: 1,
+    panel_groups: [
+      {
+        group: 'System metrics (Kubernetes)',
+        priority: 5,
+        panels: [
+          {
+            title: 'Memory Usage (Total)',
+            type: 'area-chart',
+            y_label: 'Total Memory Used',
+            weight: 4,
+            metrics: [
+              {
+                id: 'system_metrics_kubernetes_container_memory_total',
+                query_range:
+                  'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job)  /1024/1024/1024',
+                label: 'Total',
+                unit: 'GB',
+                metric_id: 12,
+              },
+            ],
+          },
+          {
+            title: 'Core Usage (Total)',
+            type: 'area-chart',
+            y_label: 'Total Cores',
+            weight: 3,
+            metrics: [
+              {
+                id: 'system_metrics_kubernetes_container_cores_total',
+                query_range:
+                  'avg(sum(rate(container_cpu_usage_seconds_total{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}[15m])) by (job)) without (job)',
+                label: 'Total',
+                unit: 'cores',
+                metric_id: 13,
+              },
+            ],
+          },
+          {
+            title: 'Memory Usage (Pod average)',
+            type: 'area-chart',
+            y_label: 'Memory Used per Pod',
+            weight: 2,
+            metrics: [
+              {
+                id: 'system_metrics_kubernetes_container_memory_average',
+                query_range:
+                  'avg(sum(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) by (job)) without (job) / count(avg(container_memory_usage_bytes{container_name!="POD",pod_name=~"^%{ci_environment_slug}-(.*)",namespace="%{kube_namespace}"}) without (job)) /1024/1024',
+                label: 'Pod average',
+                unit: 'MB',
+                metric_id: 14,
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  },
+  status: 'success',
+};
diff --git a/spec/javascripts/monitoring/store/actions_spec.js b/spec/javascripts/monitoring/store/actions_spec.js
index a848cd24fe3..31f156b0785 100644
--- a/spec/javascripts/monitoring/store/actions_spec.js
+++ b/spec/javascripts/monitoring/store/actions_spec.js
@@ -3,6 +3,9 @@ import MockAdapter from 'axios-mock-adapter';
 import store from '~/monitoring/stores';
 import * as types from '~/monitoring/stores/mutation_types';
 import {
+  fetchDashboard,
+  receiveMetricsDashboardSuccess,
+  receiveMetricsDashboardFailure,
   fetchDeploymentsData,
   fetchEnvironmentsData,
   requestMetricsData,
@@ -12,7 +15,7 @@ import {
 import storeState from '~/monitoring/stores/state';
 import testAction from 'spec/helpers/vuex_action_helper';
 import { resetStore } from '../helpers';
-import { deploymentData, environmentData } from '../mock_data';
+import { deploymentData, environmentData, metricsDashboardResponse } from '../mock_data';
 
 describe('Monitoring store actions', () => {
   let mock;
@@ -155,4 +158,88 @@ describe('Monitoring store actions', () => {
       );
     });
   });
+
+  describe('fetchDashboard', () => {
+    let dispatch;
+    let state;
+    const response = metricsDashboardResponse;
+
+    beforeEach(() => {
+      dispatch = jasmine.createSpy();
+      state = storeState();
+      state.dashboardEndpoint = '/dashboard';
+    });
+
+    it('dispatches receive and success actions', done => {
+      const params = {};
+      mock.onGet(state.dashboardEndpoint).reply(200, response);
+
+      fetchDashboard({ state, dispatch }, params)
+        .then(() => {
+          expect(dispatch).toHaveBeenCalledWith('requestMetricsDashboard');
+          expect(dispatch).toHaveBeenCalledWith('receiveMetricsDashboardSuccess', {
+            response,
+          });
+          done();
+        })
+        .catch(done.fail);
+    });
+
+    it('dispatches failure action', done => {
+      const params = {};
+      mock.onGet(state.dashboardEndpoint).reply(500);
+
+      fetchDashboard({ state, dispatch }, params)
+        .then(() => {
+          expect(dispatch).toHaveBeenCalledWith(
+            'receiveMetricsDashboardFailure',
+            new Error('Request failed with status code 500'),
+          );
+          done();
+        })
+        .catch(done.fail);
+    });
+  });
+
+  describe('receiveMetricsDashboardSuccess', () => {
+    let commit;
+    let dispatch;
+
+    beforeEach(() => {
+      commit = jasmine.createSpy();
+      dispatch = jasmine.createSpy();
+    });
+
+    it('stores groups ', () => {
+      const params = {};
+      const response = metricsDashboardResponse;
+
+      receiveMetricsDashboardSuccess({ commit, dispatch }, { response, params });
+
+      expect(commit).toHaveBeenCalledWith(
+        types.RECEIVE_METRICS_DATA_SUCCESS,
+        metricsDashboardResponse.dashboard.panel_groups,
+      );
+    });
+  });
+
+  describe('receiveMetricsDashboardFailure', () => {
+    let commit;
+
+    beforeEach(() => {
+      commit = jasmine.createSpy();
+    });
+
+    it('commits failure action', () => {
+      receiveMetricsDashboardFailure({ commit });
+
+      expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, undefined);
+    });
+
+    it('commits failure action with error', () => {
+      receiveMetricsDashboardFailure({ commit }, 'uh-oh');
+
+      expect(commit).toHaveBeenCalledWith(types.RECEIVE_METRICS_DATA_FAILURE, 'uh-oh');
+    });
+  });
 });
diff --git a/spec/javascripts/monitoring/store/mutations_spec.js b/spec/javascripts/monitoring/store/mutations_spec.js
index 882ee1dec14..bce399ece74 100644
--- a/spec/javascripts/monitoring/store/mutations_spec.js
+++ b/spec/javascripts/monitoring/store/mutations_spec.js
@@ -1,7 +1,7 @@
 import mutations from '~/monitoring/stores/mutations';
 import * as types from '~/monitoring/stores/mutation_types';
 import state from '~/monitoring/stores/state';
-import { metricsGroupsAPIResponse, deploymentData } from '../mock_data';
+import { metricsGroupsAPIResponse, deploymentData, metricsDashboardResponse } from '../mock_data';
 
 describe('Monitoring mutations', () => {
   let stateCopy;
@@ -11,14 +11,16 @@ describe('Monitoring mutations', () => {
   });
 
   describe(types.RECEIVE_METRICS_DATA_SUCCESS, () => {
+    let groups;
+
     beforeEach(() => {
       stateCopy.groups = [];
-      const groups = metricsGroupsAPIResponse.data;
-
-      mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+      groups = metricsGroupsAPIResponse.data;
     });
 
     it('normalizes values', () => {
+      mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+
       const expectedTimestamp = '2017-05-25T08:22:34.925Z';
       const expectedValue = 0.0010794445585559514;
       const [timestamp, value] = stateCopy.groups[0].metrics[0].queries[0].result[0].values[0];
@@ -28,22 +30,27 @@ describe('Monitoring mutations', () => {
     });
 
     it('contains two groups that contains, one of which has two queries sorted by priority', () => {
+      mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+
       expect(stateCopy.groups).toBeDefined();
       expect(stateCopy.groups.length).toEqual(2);
       expect(stateCopy.groups[0].metrics.length).toEqual(2);
     });
 
     it('assigns queries a metric id', () => {
+      mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+
       expect(stateCopy.groups[1].metrics[0].queries[0].metricId).toEqual('100');
     });
 
     it('removes the data if all the values from a query are not defined', () => {
+      mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
+
       expect(stateCopy.groups[1].metrics[0].queries[0].result.length).toEqual(0);
     });
 
     it('assigns metric id of null if metric has no id', () => {
       stateCopy.groups = [];
-      const groups = metricsGroupsAPIResponse.data;
       const noId = groups.map(group => ({
         ...group,
         ...{
@@ -63,6 +70,26 @@ describe('Monitoring mutations', () => {
         });
       });
     });
+
+    describe('dashboard endpoint enabled', () => {
+      const dashboardGroups = metricsDashboardResponse.dashboard.panel_groups;
+
+      beforeEach(() => {
+        stateCopy.useDashboardEndpoint = true;
+      });
+
+      it('aliases group panels to metrics for backwards compatibility', () => {
+        mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
+
+        expect(stateCopy.groups[0].metrics[0]).toBeDefined();
+      });
+
+      it('aliases panel metrics to queries for backwards compatibility', () => {
+        mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, dashboardGroups);
+
+        expect(stateCopy.groups[0].metrics[0].queries).toBeDefined();
+      });
+    });
   });
 
   describe(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, () => {
@@ -82,11 +109,13 @@ describe('Monitoring mutations', () => {
         metricsEndpoint: 'additional_metrics.json',
         environmentsEndpoint: 'environments.json',
         deploymentsEndpoint: 'deployments.json',
+        dashboardEndpoint: 'dashboard.json',
       });
 
       expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json');
       expect(stateCopy.environmentsEndpoint).toEqual('environments.json');
       expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json');
+      expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json');
     });
   });
 });
-- 
2.30.9