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'); }); }); });