Commit 26ea63ae authored by Miguel Rincon's avatar Miguel Rincon

Add store action that fetch data for preview panel

This change implements the backend endpoint integration that
fetches data from prometheus to display data in the chart preview.
parent 018ffad5
......@@ -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);
// eslint-disable-next-line @gitlab/require-i18n-strings
throw new Error('Not implemented');
state.panelPreviewGraphData.metrics.forEach((metric, index) => {
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';
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;
const metricState = metricStateFromData(data);
if (!data.result || data.result.length === 0) {
metric.state = metricStates.NO_DATA;
metric.result = null;
} else {
const result = normalizeQueryResponseData(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,
[
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: 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';
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