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 {
} from '@gitlab/ui';
import DashboardPanel from './dashboard_panel.vue';
const initialYml = `title:
y_label:
const initialYml = `title: Go heap size
type: area-chart
y_axis:
format: 'bytes'
metrics:
- query_range:
label:
- metric_id: 'go_memstats_alloc_bytes_1'
query_range: 'go_memstats_alloc_bytes'
`;
export default {
......
......@@ -41,12 +41,3 @@ export const getPrometheusQueryData = (prometheusEndpoint, params) =>
}
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';
import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql';
import { convertObjectPropsToCamelCase } from '../../lib/utils/common_utils';
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';
......@@ -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
export const setGettingStartedEmptyState = ({ commit }) => {
......@@ -482,21 +497,38 @@ export const fetchPanelPreview = ({ state, commit, dispatch }, panelPreviewYml)
}
commit(types.REQUEST_PANEL_PREVIEW, panelPreviewYml);
return getPanelJson(state.panelPreviewEndpoint, panelPreviewYml)
.then(data => {
return axios
.post(state.panelPreviewEndpoint, { panel_yaml: panelPreviewYml })
.then(({ data }) => {
commit(types.RECEIVE_PANEL_PREVIEW_SUCCESS, data);
dispatch('fetchPanelPreviewMetrics');
})
.catch(error => {
commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, error);
commit(types.RECEIVE_PANEL_PREVIEW_FAILURE, extractErrorMessage(error));
});
};
export const fetchPanelPreviewMetrics = () => {
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
export const fetchPanelPreviewMetrics = ({ state, commit }) => {
const defaultQueryParams = prometheusMetricQueryParams(defaultTimeRange);
state.panelPreviewGraphData.metrics.forEach((metric, index) => {
commit(types.REQUEST_PANEL_PREVIEW_METRIC_RESULT, { index });
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Not implemented');
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';
export const REQUEST_PANEL_PREVIEW = 'REQUEST_PANEL_PREVIEW';
export const RECEIVE_PANEL_PREVIEW_SUCCESS = 'RECEIVE_PANEL_PREVIEW_SUCCESS';
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 => {
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 {
/**
* Dashboard panels structure and global state
......@@ -154,17 +162,11 @@ export default {
},
[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) {
const metric = findMetricInDashboard(metricId, state.dashboard);
metric.loading = false;
if (!data.result || data.result.length === 0) {
metric.state = metricStates.NO_DATA;
metric.result = null;
} else {
const result = normalizeQueryResponseData(data);
const metricState = metricStateFromData(data);
metric.state = metricStates.OK;
metric.result = Object.freeze(result);
}
metric.loading = false;
metric.state = metricState.state;
metric.result = metricState.result;
},
[types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) {
const metric = findMetricInDashboard(metricId, state.dashboard);
......@@ -238,4 +240,28 @@ export default {
state.panelPreviewGraphData = null;
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;
const makeValue = val => [initTime, 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
const scalarResult = ({ value = '1' } = {}) =>
......
......@@ -9,7 +9,6 @@ import { defaultTimeRange } from '~/vue_shared/constants';
import * as getters from '~/monitoring/stores/getters';
import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants';
import { backoffMockImplementation } from 'jest/helpers/backoff_helper';
import * as requests from '~/monitoring/requests';
import { createStore } from '~/monitoring/stores';
import * as types from '~/monitoring/stores/mutation_types';
......@@ -1158,54 +1157,64 @@ describe('Monitoring store actions', () => {
});
describe('fetchPanelPreview', () => {
const panelPreviewEndpoint = '/builder.json';
const mockYmlContent = 'mock yml content';
beforeEach(() => {
state.panelPreviewEndpoint = panelPreviewEndpoint;
});
it('should not commit or dispatch if payload is empty', () => {
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 = {
title: 'title',
title: 'Go heap size',
type: 'area-chart',
};
// TODO Use a axios mock instead of spy when backend is implemented
// https://gitlab.com/gitlab-org/gitlab/-/issues/228758
jest.spyOn(requests, 'getPanelJson').mockResolvedValue(mockPanel);
mock
.onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent })
.reply(statusCodes.OK, mockPanel);
testAction(
fetchPanelPreview,
'mock yml content',
mockYmlContent,
state,
[
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel },
],
[
{
type: 'fetchPanelPreviewMetrics',
},
],
[{ type: 'fetchPanelPreviewMetrics' }],
);
});
it('should commit a failure when backend fails', () => {
const mockError = 'error';
// 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);
it('should display a validation error when the backend cannot process the yml', () => {
const mockErrorMsg = 'Each "metric" must define one of :query or :query_range';
testAction(
fetchPanelPreview,
mockYmlContent,
state,
[
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockError },
],
[],
);
mock
.onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent })
.reply(statusCodes.UNPROCESSABLE_ENTITY, {
message: mockErrorMsg,
});
testAction(fetchPanelPreview, mockYmlContent, state, [
{ type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent },
{ 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';
import * as types from '~/monitoring/stores/mutation_types';
import state from '~/monitoring/stores/state';
import { dashboardEmptyStates, metricStates } from '~/monitoring/constants';
import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data';
import { prometheusMatrixMultiResult } from '../graph_data';
import { metricsDashboardPayload } from '../fixture_data';
describe('Monitoring mutations', () => {
......@@ -259,27 +259,6 @@ describe('Monitoring mutations', () => {
describe('Individual panel/metric results', () => {
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 getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0];
......@@ -307,6 +286,8 @@ describe('Monitoring mutations', () => {
});
it('adds results to the store', () => {
const data = prometheusMatrixMultiResult();
expect(getMetric().result).toBe(null);
mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, {
......@@ -526,4 +507,90 @@ describe('Monitoring mutations', () => {
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