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';
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;
......
......@@ -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,
......
......@@ -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([]);
......
......@@ -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';
......@@ -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';
......
......@@ -4,6 +4,8 @@ export default () => ({
metricsEndpoint: null,
environmentsEndpoint: null,
deploymentsEndpoint: null,
dashboardEndpoint: null,
useDashboardEndpoint: false,
emptyState: 'gettingStarted',
showEmptyState: true,
groups: [],
......
......@@ -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),
]),
......
......@@ -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),
......
......@@ -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',
};
......@@ -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');
});
});
});
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');
});
});
});
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