Commit fba991dc authored by Simon Knox's avatar Simon Knox

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
parent ecdc50b1
/**
* 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';
...@@ -13,6 +13,7 @@ import { s__ } from '~/locale'; ...@@ -13,6 +13,7 @@ import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import '~/vue_shared/mixins/is_ee'; import '~/vue_shared/mixins/is_ee';
import { getParameterValues } from '~/lib/utils/url_utility'; import { getParameterValues } from '~/lib/utils/url_utility';
import invalidUrl from '~/lib/utils/invalid_url';
import MonitorAreaChart from './charts/area.vue'; import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue'; import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue'; import EmptyState from './empty_state.vue';
...@@ -123,6 +124,11 @@ export default { ...@@ -123,6 +124,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
dashboardEndpoint: {
type: String,
required: false,
default: invalidUrl,
},
}, },
data() { data() {
return { return {
...@@ -150,6 +156,7 @@ export default { ...@@ -150,6 +156,7 @@ export default {
metricsEndpoint: this.metricsEndpoint, metricsEndpoint: this.metricsEndpoint,
environmentsEndpoint: this.environmentsEndpoint, environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentEndpoint, deploymentsEndpoint: this.deploymentEndpoint,
dashboardEndpoint: this.dashboardEndpoint,
}); });
this.timeWindows = timeWindows; this.timeWindows = timeWindows;
......
...@@ -7,6 +7,11 @@ export default (props = {}) => { ...@@ -7,6 +7,11 @@ export default (props = {}) => {
const el = document.getElementById('prometheus-graphs'); const el = document.getElementById('prometheus-graphs');
if (el && el.dataset) { if (el && el.dataset) {
store.dispatch(
'monitoringDashboard/setDashboardEnabled',
gon.features.environmentMetricsUsePrometheusEndpoint,
);
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new Vue({ new Vue({
el, el,
......
...@@ -35,6 +35,20 @@ export const setEndpoints = ({ commit }, endpoints) => { ...@@ -35,6 +35,20 @@ export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, 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 requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
export const receiveMetricsDataSuccess = ({ commit }, data) => export const receiveMetricsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_METRICS_DATA_SUCCESS, data); commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
...@@ -56,6 +70,10 @@ export const fetchData = ({ dispatch }, params) => { ...@@ -56,6 +70,10 @@ export const fetchData = ({ dispatch }, params) => {
}; };
export const fetchMetricsData = ({ state, dispatch }, params) => { export const fetchMetricsData = ({ state, dispatch }, params) => {
if (state.useDashboardEndpoint) {
return dispatch('fetchDashboard', params);
}
dispatch('requestMetricsData'); dispatch('requestMetricsData');
return backOffRequest(() => axios.get(state.metricsEndpoint, { params })) return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
...@@ -73,6 +91,21 @@ export const fetchMetricsData = ({ state, dispatch }, 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 }) => { export const fetchDeploymentsData = ({ state, dispatch }) => {
if (!state.deploymentEndpoint) { if (!state.deploymentEndpoint) {
return Promise.resolve([]); return Promise.resolve([]);
......
...@@ -8,5 +8,6 @@ export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA'; ...@@ -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_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE'; export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW'; 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_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
...@@ -7,10 +7,24 @@ export default { ...@@ -7,10 +7,24 @@ export default {
state.showEmptyState = true; state.showEmptyState = true;
}, },
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) { [types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.groups = groupData.map(group => ({ state.groups = groupData.map(group => {
...group, let { metrics } = group;
metrics: normalizeMetrics(sortMetrics(group.metrics)),
})); // 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) { if (!state.groups.length) {
state.emptyState = 'noData'; state.emptyState = 'noData';
...@@ -38,6 +52,10 @@ export default { ...@@ -38,6 +52,10 @@ export default {
state.metricsEndpoint = endpoints.metricsEndpoint; state.metricsEndpoint = endpoints.metricsEndpoint;
state.environmentsEndpoint = endpoints.environmentsEndpoint; state.environmentsEndpoint = endpoints.environmentsEndpoint;
state.deploymentsEndpoint = endpoints.deploymentsEndpoint; state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
state.dashboardEndpoint = endpoints.dashboardEndpoint;
},
[types.SET_DASHBOARD_ENABLED](state, enabled) {
state.useDashboardEndpoint = enabled;
}, },
[types.SET_GETTING_STARTED_EMPTY_STATE](state) { [types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted'; state.emptyState = 'gettingStarted';
......
...@@ -4,6 +4,8 @@ export default () => ({ ...@@ -4,6 +4,8 @@ export default () => ({
metricsEndpoint: null, metricsEndpoint: null,
environmentsEndpoint: null, environmentsEndpoint: null,
deploymentsEndpoint: null, deploymentsEndpoint: null,
dashboardEndpoint: null,
useDashboardEndpoint: false,
emptyState: 'gettingStarted', emptyState: 'gettingStarted',
showEmptyState: true, showEmptyState: true,
groups: [], groups: [],
......
...@@ -66,9 +66,9 @@ export const normalizeMetrics = metrics => { ...@@ -66,9 +66,9 @@ export const normalizeMetrics = metrics => {
...query, ...query,
// custom metrics do not require a label, so we should ensure this attribute is defined // custom metrics do not require a label, so we should ensure this attribute is defined
label: query.label || metric.y_label, label: query.label || metric.y_label,
result: query.result.map(result => ({ result: (query.result || []).map(timeSeries => ({
...result, ...timeSeries,
values: result.values.map(([timestamp, value]) => [ values: timeSeries.values.map(([timestamp, value]) => [
new Date(timestamp * 1000).toISOString(), new Date(timestamp * 1000).toISOString(),
Number(value), Number(value),
]), ]),
......
...@@ -26,6 +26,7 @@ module EnvironmentsHelper ...@@ -26,6 +26,7 @@ module EnvironmentsHelper
"empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'), "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'), "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), "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), "deployment-endpoint" => project_environment_deployments_path(project, environment, format: :json),
"environments-endpoint": project_environments_path(project, format: :json), "environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project), "project-path" => project_path(project),
......
...@@ -857,3 +857,67 @@ export const environmentData = [ ...@@ -857,3 +857,67 @@ export const environmentData = [
updated_at: '2018-07-04T18:44:54.010Z', 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',
};
...@@ -3,6 +3,9 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,9 @@ import MockAdapter from 'axios-mock-adapter';
import store from '~/monitoring/stores'; import store from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import { import {
fetchDashboard,
receiveMetricsDashboardSuccess,
receiveMetricsDashboardFailure,
fetchDeploymentsData, fetchDeploymentsData,
fetchEnvironmentsData, fetchEnvironmentsData,
requestMetricsData, requestMetricsData,
...@@ -12,7 +15,7 @@ import { ...@@ -12,7 +15,7 @@ import {
import storeState from '~/monitoring/stores/state'; import storeState from '~/monitoring/stores/state';
import testAction from 'spec/helpers/vuex_action_helper'; import testAction from 'spec/helpers/vuex_action_helper';
import { resetStore } from '../helpers'; import { resetStore } from '../helpers';
import { deploymentData, environmentData } from '../mock_data'; import { deploymentData, environmentData, metricsDashboardResponse } from '../mock_data';
describe('Monitoring store actions', () => { describe('Monitoring store actions', () => {
let mock; let mock;
...@@ -155,4 +158,88 @@ describe('Monitoring store actions', () => { ...@@ -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');
});
});
}); });
import mutations from '~/monitoring/stores/mutations'; import mutations from '~/monitoring/stores/mutations';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
import state from '~/monitoring/stores/state'; import state from '~/monitoring/stores/state';
import { metricsGroupsAPIResponse, deploymentData } from '../mock_data'; import { metricsGroupsAPIResponse, deploymentData, metricsDashboardResponse } from '../mock_data';
describe('Monitoring mutations', () => { describe('Monitoring mutations', () => {
let stateCopy; let stateCopy;
...@@ -11,14 +11,16 @@ describe('Monitoring mutations', () => { ...@@ -11,14 +11,16 @@ describe('Monitoring mutations', () => {
}); });
describe(types.RECEIVE_METRICS_DATA_SUCCESS, () => { describe(types.RECEIVE_METRICS_DATA_SUCCESS, () => {
let groups;
beforeEach(() => { beforeEach(() => {
stateCopy.groups = []; stateCopy.groups = [];
const groups = metricsGroupsAPIResponse.data; groups = metricsGroupsAPIResponse.data;
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
}); });
it('normalizes values', () => { it('normalizes values', () => {
mutations[types.RECEIVE_METRICS_DATA_SUCCESS](stateCopy, groups);
const expectedTimestamp = '2017-05-25T08:22:34.925Z'; const expectedTimestamp = '2017-05-25T08:22:34.925Z';
const expectedValue = 0.0010794445585559514; const expectedValue = 0.0010794445585559514;
const [timestamp, value] = stateCopy.groups[0].metrics[0].queries[0].result[0].values[0]; const [timestamp, value] = stateCopy.groups[0].metrics[0].queries[0].result[0].values[0];
...@@ -28,22 +30,27 @@ describe('Monitoring mutations', () => { ...@@ -28,22 +30,27 @@ describe('Monitoring mutations', () => {
}); });
it('contains two groups that contains, one of which has two queries sorted by priority', () => { 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).toBeDefined();
expect(stateCopy.groups.length).toEqual(2); expect(stateCopy.groups.length).toEqual(2);
expect(stateCopy.groups[0].metrics.length).toEqual(2); expect(stateCopy.groups[0].metrics.length).toEqual(2);
}); });
it('assigns queries a metric id', () => { 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'); 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', () => { 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); expect(stateCopy.groups[1].metrics[0].queries[0].result.length).toEqual(0);
}); });
it('assigns metric id of null if metric has no id', () => { it('assigns metric id of null if metric has no id', () => {
stateCopy.groups = []; stateCopy.groups = [];
const groups = metricsGroupsAPIResponse.data;
const noId = groups.map(group => ({ const noId = groups.map(group => ({
...group, ...group,
...{ ...{
...@@ -63,6 +70,26 @@ describe('Monitoring mutations', () => { ...@@ -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, () => { describe(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, () => {
...@@ -82,11 +109,13 @@ describe('Monitoring mutations', () => { ...@@ -82,11 +109,13 @@ describe('Monitoring mutations', () => {
metricsEndpoint: 'additional_metrics.json', metricsEndpoint: 'additional_metrics.json',
environmentsEndpoint: 'environments.json', environmentsEndpoint: 'environments.json',
deploymentsEndpoint: 'deployments.json', deploymentsEndpoint: 'deployments.json',
dashboardEndpoint: 'dashboard.json',
}); });
expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json'); expect(stateCopy.metricsEndpoint).toEqual('additional_metrics.json');
expect(stateCopy.environmentsEndpoint).toEqual('environments.json'); expect(stateCopy.environmentsEndpoint).toEqual('environments.json');
expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json'); expect(stateCopy.deploymentsEndpoint).toEqual('deployments.json');
expect(stateCopy.dashboardEndpoint).toEqual('dashboard.json');
}); });
}); });
}); });
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment