Commit 633eed8f authored by Ezekiel Kigbo's avatar Ezekiel Kigbo

Merge branch '228761-add-metric-result-to-panel-preview' into 'master'

Fetch metrics results in panel preview

See merge request gitlab-org/gitlab!37930
parents ac4beb53 26ea63ae
...@@ -11,12 +11,13 @@ import { ...@@ -11,12 +11,13 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import DashboardPanel from './dashboard_panel.vue'; import DashboardPanel from './dashboard_panel.vue';
const initialYml = `title: const initialYml = `title: Go heap size
y_label:
type: area-chart type: area-chart
y_axis:
format: 'bytes'
metrics: metrics:
- query_range: - metric_id: 'go_memstats_alloc_bytes_1'
label: query_range: 'go_memstats_alloc_bytes'
`; `;
export default { export default {
......
...@@ -41,12 +41,3 @@ export const getPrometheusQueryData = (prometheusEndpoint, params) => ...@@ -41,12 +41,3 @@ export const getPrometheusQueryData = (prometheusEndpoint, params) =>
} }
throw error; throw error;
}); });
// eslint-disable-next-line no-unused-vars
export function getPanelJson(panelPreviewEndpoint, panelPreviewYml) {
// TODO Use a real backend when it's available
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
// eslint-disable-next-line @gitlab/require-i18n-strings
return Promise.reject(new Error('API Not implemented.'));
}
...@@ -15,7 +15,8 @@ import getAnnotations from '../queries/getAnnotations.query.graphql'; ...@@ -15,7 +15,8 @@ import getAnnotations from '../queries/getAnnotations.query.graphql';
import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
import { s__, sprintf } from '../../locale'; import { s__, sprintf } from '../../locale';
import { getDashboard, getPrometheusQueryData, getPanelJson } from '../requests'; import { getDashboard, getPrometheusQueryData } from '../requests';
import { defaultTimeRange } from '~/vue_shared/constants';
import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants'; import { ENVIRONMENT_AVAILABLE_STATE, OVERVIEW_DASHBOARD_PATH, VARIABLE_TYPES } from '../constants';
...@@ -33,6 +34,20 @@ function prometheusMetricQueryParams(timeRange) { ...@@ -33,6 +34,20 @@ function prometheusMetricQueryParams(timeRange) {
}; };
} }
/**
* Extract error messages from API or HTTP request errors.
*
* - API errors are in `error.response.data.message`
* - HTTP (axios) errors are in `error.messsage`
*
* @param {Object} error
* @returns {String} User friendly error message
*/
function extractErrorMessage(error) {
const message = error?.response?.data?.message;
return message ?? error.message;
}
// Setup // Setup
export const setGettingStartedEmptyState = ({ commit }) => { export const setGettingStartedEmptyState = ({ commit }) => {
...@@ -482,21 +497,38 @@ export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml) ...@@ -482,21 +497,38 @@ export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml)
} }
commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml); commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml);
return getPanelJson(state.panelPreviewEndpoint, panelPreviewYml) return axios
.then(data => { .post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml })
.then(({ data }) => {
commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data); commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data);
dispatch('fetchPanelPreviewMetrics'); dispatch('fetchPanelPreviewMetrics');
}) })
.catch(error => { .catch(error => {
commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, error); commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error));
}); });
}; };
export const fetchPanelPreviewMetrics = () => { export const fetchPanelPreviewMetrics = ({ state, commit }) => {
// TODO Use a axios mock instead of spy when backend is implemented const defaultQueryParams = prometheusMetricQueryParams(defaultTimeRange);
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
// eslint-disable-next-line @gitlab/require-i18n-strings state.panelPreviewGraphData.metrics.forEach((metric, index) => {
throw new Error('Not implemented'); commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index });
const params = { ...defaultQueryParams };
if (metric.step) {
params.step = metric.step;
}
return getPrometheusQueryData(metric.prometheusEndpointPath, params)
.then(data => {
commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS, { index, data });
})
.catch(error => {
Sentry.captureException(error);
commit(types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE, { index, error });
// Continue to throw error so the panel builder can notify using createFlash
throw error;
});
});
}; };
...@@ -51,3 +51,9 @@ export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL'; ...@@ -51,3 +51,9 @@ export const SET_EXPANDED_PANEL = 'SET_EXPANDED_PANEL';
export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW'; export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW';
export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS'; export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS';
export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE'; export const RECEIVE_PANEL_PREVIEW_FAILURE = 'RECEIVE_PANEL_PREVIEW_FAILURE';
export const REQUEST_PANEL_PREVIEW_METRIC_RESULT = 'REQUEST_PANEL_PREVIEW_METRIC_RESULT';
export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS =
'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS';
export const RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE =
'RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE';
...@@ -53,6 +53,14 @@ const emptyStateFromError = error => { ...@@ -53,6 +53,14 @@ const emptyStateFromError = error => {
return metricStates.UNKNOWN_ERROR; return metricStates.UNKNOWN_ERROR;
}; };
export const metricStateFromData = data => {
if (data?.result?.length) {
const result = normalizeQueryResponseData(data);
return { state: metricStates.OK, result: Object.freeze(result) };
}
return { state: metricStates.NO_DATA, result: null };
};
export default { export default {
/** /**
* Dashboard panels structure and global state * Dashboard panels structure and global state
...@@ -154,17 +162,11 @@ export default { ...@@ -154,17 +162,11 @@ export default {
}, },
[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) {
const metric = findMetricInDashboard(metricId, state.dashboard); const metric = findMetricInDashboard(metricId, state.dashboard);
metric.loading = false; const metricState = metricStateFromData(data);
if (!data.result || data.result.length === 0) { metric.loading = false;
metric.state = metricStates.NO_DATA; metric.state = metricState.state;
metric.result = null; metric.result = metricState.result;
} else {
const result = normalizeQueryResponseData(data);
metric.state = metricStates.OK;
metric.result = Object.freeze(result);
}
}, },
[types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
const metric = findMetricInDashboard(metricId, state.dashboard); const metric = findMetricInDashboard(metricId, state.dashboard);
...@@ -238,4 +240,28 @@ export default { ...@@ -238,4 +240,28 @@ export default {
state.panelPreviewGraphData = null; state.panelPreviewGraphData = null;
state.panelPreviewError = error; state.panelPreviewError = error;
}, },
[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](state, { index }) {
const metric = state.panelPreviewGraphData.metrics[index];
metric.loading = true;
if (!metric.result) {
metric.state = metricStates.LOADING;
}
},
[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](state, { index, data }) {
const metric = state.panelPreviewGraphData.metrics[index];
const metricState = metricStateFromData(data);
metric.loading = false;
metric.state = metricState.state;
metric.result = metricState.result;
},
[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](state, { index, error }) {
const metric = state.panelPreviewGraphData.metrics[index];
metric.loading = false;
metric.state = emptyStateFromError(error);
metric.result = null;
},
}; };
...@@ -7,6 +7,33 @@ const intervalSeconds = 120; ...@@ -7,6 +7,33 @@ const intervalSeconds = 120;
const makeValue = val => [initTime, val]; const makeValue = val => [initTime, val];
const makeValues = vals => vals.map((val, i) => [initTime + intervalSeconds * i, val]); const makeValues = vals => vals.map((val, i) => [initTime + intervalSeconds * i, val]);
// Raw Promethues Responses
export const prometheusMatrixMultiResult = ({
values1 = ['1', '2', '3'],
values2 = ['4', '5', '6'],
} = {}) => ({
resultType: 'matrix',
result: [
{
metric: {
__name__: 'up',
job: 'prometheus',
instance: 'localhost:9090',
},
values: makeValues(values1),
},
{
metric: {
__name__: 'up',
job: 'node',
instance: 'localhost:9091',
},
values: makeValues(values2),
},
],
});
// Normalized Prometheus Responses // Normalized Prometheus Responses
const scalarResult = ({ value = '1' } = {}) => const scalarResult = ({ value = '1' } = {}) =>
......
...@@ -9,7 +9,6 @@ import { defaultTimeRange } from '~/vue_shared/constants'; ...@@ -9,7 +9,6 @@ import { defaultTimeRange } from '~/vue_shared/constants';
import * as getters from '~/monitoring/stores/getters'; import * as getters from '~/monitoring/stores/getters';
import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import { backoffMockImplementation } from 'jest/helpers/backoff_helper'; import { backoffMockImplementation } from 'jest/helpers/backoff_helper';
import * as requests from '~/monitoring/requests';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types'; import * as types from '~/monitoring/stores/mutation_types';
...@@ -1158,54 +1157,64 @@ describe('Monitoring store actions', () => { ...@@ -1158,54 +1157,64 @@ describe('Monitoring store actions', () => {
}); });
describe('fetchPanelPreview', () => { describe('fetchPanelPreview', () => {
const panelPreviewEndpoint = '/builder.json';
const mockYmlContent = 'mock yml content'; const mockYmlContent = 'mock yml content';
beforeEach(() => {
state.panelPreviewEndpoint = panelPreviewEndpoint;
});
it('should not commit or dispatch if payload is empty', () => { it('should not commit or dispatch if payload is empty', () => {
testAction(fetchPanelPreview, '', state, [], []); testAction(fetchPanelPreview, '', state, [], []);
}); });
it('should store the yml content and panel in the store and fetch corresponding metrics', () => { it('should store the panel and fetch metric results', () => {
const mockPanel = { const mockPanel = {
title: 'title', title: 'Go heap size',
type: 'area-chart', type: 'area-chart',
}; };
// TODO Use a axios mock instead of spy when backend is implemented mock
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758 .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent })
jest.spyOn(requests, 'getPanelJson').mockResolvedValue(mockPanel); .reply(statusCodes.OK, mockPanel);
testAction( testAction(
fetchPanelPreview, fetchPanelPreview,
'mock yml content', mockYmlContent,
state, state,
[ [
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel }, { type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel },
], ],
[ [{ type: 'fetchPanelPreviewMetrics' }],
{
type: 'fetchPanelPreviewMetrics',
},
],
); );
}); });
it('should commit a failure when backend fails', () => { it('should display a validation error when the backend cannot process the yml', () => {
const mockError = 'error'; const mockErrorMsg = 'Each "metric" must define one of :query or :query_range';
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
jest.spyOn(requests, 'getPanelJson').mockRejectedValue(mockError);
testAction( mock
fetchPanelPreview, .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent })
mockYmlContent, .reply(statusCodes.UNPROCESSABLE_ENTITY, {
state, message: mockErrorMsg,
[ });
testAction(fetchPanelPreview, mockYmlContent, state, [
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockError }, { type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockErrorMsg },
], ]);
[], });
);
it('should display a generic error when the backend fails', () => {
mock.onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }).reply(500);
testAction(fetchPanelPreview, mockYmlContent, state, [
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{
type: types.RECEIVE_PANEL_PREVIEW_FAILURE,
payload: 'Request failed with status code 500',
},
]);
}); });
}); });
}); });
...@@ -4,8 +4,8 @@ import mutations from '~/monitoring/stores/mutations'; ...@@ -4,8 +4,8 @@ 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 { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants';
import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data';
import { prometheusMatrixMultiResult } from '../graph_data';
import { metricsDashboardPayload } from '../fixture_data'; import { metricsDashboardPayload } from '../fixture_data';
describe('Monitoring mutations', () => { describe('Monitoring mutations', () => {
...@@ -259,27 +259,6 @@ describe('Monitoring mutations', () => { ...@@ -259,27 +259,6 @@ describe('Monitoring mutations', () => {
describe('Individual panel/metric results', () => { describe('Individual panel/metric results', () => {
const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code';
const data = {
resultType: 'matrix',
result: [
{
metric: {
__name__: 'up',
job: 'prometheus',
instance: 'localhost:9090',
},
values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']],
},
{
metric: {
__name__: 'up',
job: 'node',
instance: 'localhost:9091',
},
values: [[1435781430.781, '0'], [1435781445.781, '0'], [1435781460.781, '1']],
},
],
};
const dashboard = metricsDashboardPayload; const dashboard = metricsDashboardPayload;
const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0]; const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0];
...@@ -307,6 +286,8 @@ describe('Monitoring mutations', () => { ...@@ -307,6 +286,8 @@ describe('Monitoring mutations', () => {
}); });
it('adds results to the store', () => { it('adds results to the store', () => {
const data = prometheusMatrixMultiResult();
expect(getMetric().result).toBe(null); expect(getMetric().result).toBe(null);
mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, {
...@@ -526,4 +507,90 @@ describe('Monitoring mutations', () => { ...@@ -526,4 +507,90 @@ describe('Monitoring mutations', () => {
expect(stateCopy.panelPreviewError).toBe('Error!'); expect(stateCopy.panelPreviewError).toBe('Error!');
}); });
}); });
describe('panel preview metric', () => {
const getPreviewMetricAt = i => stateCopy.panelPreviewGraphData.metrics[i];
beforeEach(() => {
stateCopy.panelPreviewGraphData = {
title: 'Preview panel title',
metrics: [
{
query: 'query',
},
],
};
});
describe('REQUEST_PANEL_PREVIEW_METRIC_RESULT', () => {
it('sets the metric to loading for the first time', () => {
mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 });
expect(getPreviewMetricAt(0).loading).toBe(true);
expect(getPreviewMetricAt(0).state).toBe(metricStates.LOADING);
});
it('sets the metric to loading and keeps the result', () => {
getPreviewMetricAt(0).result = [[0, 1]];
getPreviewMetricAt(0).state = metricStates.OK;
mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 });
expect(getPreviewMetricAt(0)).toMatchObject({
loading: true,
result: [[0, 1]],
state: metricStates.OK,
});
});
});
describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS', () => {
it('saves the result in the metric', () => {
const data = prometheusMatrixMultiResult();
mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](stateCopy, {
index: 0,
data,
});
expect(getPreviewMetricAt(0)).toMatchObject({
loading: false,
state: metricStates.OK,
result: expect.any(Array),
});
expect(getPreviewMetricAt(0).result).toHaveLength(data.result.length);
});
});
describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE', () => {
it('stores an error in the metric', () => {
mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, {
index: 0,
});
expect(getPreviewMetricAt(0).loading).toBe(false);
expect(getPreviewMetricAt(0).state).toBe(metricStates.UNKNOWN_ERROR);
expect(getPreviewMetricAt(0).result).toBe(null);
expect(getPreviewMetricAt(0)).toMatchObject({
loading: false,
result: null,
state: metricStates.UNKNOWN_ERROR,
});
});
it('stores a timeout error in a metric', () => {
mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, {
index: 0,
error: { message: 'BACKOFF_TIMEOUT' },
});
expect(getPreviewMetricAt(0)).toMatchObject({
loading: false,
result: null,
state: metricStates.TIMEOUT,
});
});
});
});
}); });
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