Commit 2c4dbd41 authored by Miguel Rincon's avatar Miguel Rincon

Fetch variable options from the Prometheus API

Add a new action to load dynamic options from the Prometheus endpoint
provided by the backend.

New options are loaded and replaced in the variables, allowing the user
to select them.
parent d9a1e681
......@@ -22,13 +22,13 @@ export default {
default: '',
},
options: {
type: Array,
type: Object,
required: true,
},
},
computed: {
defaultText() {
const selectedOpt = this.options.find(opt => opt.value === this.value);
text() {
const selectedOpt = this.options.values?.find(opt => opt.value === this.value);
return selectedOpt?.text || this.value;
},
},
......@@ -41,10 +41,13 @@ export default {
</script>
<template>
<gl-form-group :label="label">
<gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText">
<gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{
opt.text
}}</gl-dropdown-item>
<gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
<gl-dropdown-item
v-for="val in options.values"
:key="val.value"
@click="onUpdate(val.value)"
>{{ val.text }}</gl-dropdown-item
>
</gl-dropdown>
</gl-form-group>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import CustomVariable from './variables/custom_variable.vue';
import TextVariable from './variables/text_variable.vue';
import DropdownField from './variables/dropdown_field.vue';
import TextField from './variables/text_field.vue';
import { setCustomVariablesFromUrl } from '../utils';
import { VARIABLE_TYPES } from '../constants';
export default {
components: {
CustomVariable,
TextVariable,
DropdownField,
TextField,
},
computed: {
...mapState('monitoringDashboard', ['variables']),
......@@ -27,12 +28,11 @@ export default {
setCustomVariablesFromUrl(this.variables);
}
},
variableComponent(type) {
const types = {
text: TextVariable,
custom: CustomVariable,
};
return types[type] || TextVariable;
variableField(type) {
if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) {
return DropdownField;
}
return TextField;
},
},
};
......@@ -41,7 +41,7 @@ export default {
<div ref="variablesSection" class="d-sm-flex flex-sm-wrap pt-2 pr-1 pb-0 pl-2 variables-section">
<div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
<component
:is="variableComponent(variable.type)"
:is="variableField(variable.type)"
class="mb-0 flex-grow-1"
:label="variable.label"
:value="variable.value"
......
......@@ -230,6 +230,7 @@ export const OPERATORS = {
export const VARIABLE_TYPES = {
custom: 'custom',
text: 'text',
metric_label_values: 'metric_label_values',
};
/**
......
......@@ -21,6 +21,7 @@ import {
PROMETHEUS_TIMEOUT,
ENVIRONMENT_AVAILABLE_STATE,
DEFAULT_DASHBOARD_PATH,
VARIABLE_TYPES,
} from '../constants';
function prometheusMetricQueryParams(timeRange) {
......@@ -191,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.reject();
}
// Time range params must be pre-calculated once for all metrics and options
// A subsequent call, may calculate a different time range
const defaultQueryParams = prometheusMetricQueryParams(state.timeRange);
dispatch('fetchVariableMetricLabelValues', { defaultQueryParams });
const promises = [];
state.dashboard.panelGroups.forEach(group => {
group.panels.forEach(panel => {
......@@ -466,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
// Variables manipulation
export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
commit(types.UPDATE_VARIABLES, updatedVariable);
commit(types.UPDATE_VARIABLE_VALUE, updatedVariable);
return dispatch('fetchDashboardData');
};
export const fetchVariableMetricLabelValues = ({ state, commit }, { defaultQueryParams }) => {
const { start_time, end_time } = defaultQueryParams;
const optionsRequests = [];
Object.entries(state.variables).forEach(([key, variable]) => {
if (variable.type === VARIABLE_TYPES.metric_label_values) {
const { prometheusEndpointPath, label } = variable.options;
const optionsRequest = backOffRequest(() =>
axios.get(prometheusEndpointPath, {
params: { start_time, end_time },
}),
)
.then(({ data }) => data.data)
.then(data => {
commit(types.UPDATE_VARIABLE_METRIC_LABEL_VALUES, { variable, label, data });
})
.catch(() => {
createFlash(
sprintf(s__('Metrics|There was an error getting options for variable "%{name}".'), {
name: key,
}),
);
});
optionsRequests.push(optionsRequest);
}
});
return Promise.all(optionsRequests);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
......@@ -3,7 +3,8 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD';
export const RECEIVE_METRICS_DASHBOARD_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
export const SET_VARIABLES = 'SET_VARIABLES';
export const UPDATE_VARIABLES = 'UPDATE_VARIABLES';
export const UPDATE_VARIABLE_VALUE = 'UPDATE_VARIABLE_VALUE';
export const UPDATE_VARIABLE_METRIC_LABEL_VALUES = 'UPDATE_VARIABLE_METRIC_LABEL_VALUES';
export const REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
......
......@@ -2,9 +2,10 @@ import Vue from 'vue';
import { pick } from 'lodash';
import * as types from './mutation_types';
import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
import { endpointKeys, initialStateKeys, metricStates } from '../constants';
import httpStatusCodes from '~/lib/utils/http_status';
import { optionsFromSeriesData } from './variable_mapping';
/**
* Locate and return a metric in the dashboard by its id
......@@ -205,10 +206,16 @@ export default {
[types.SET_VARIABLES](state, variables) {
state.variables = variables;
},
[types.UPDATE_VARIABLES](state, updatedVariable) {
Object.assign(state.variables[updatedVariable.key], {
...state.variables[updatedVariable.key],
value: updatedVariable.value,
[types.UPDATE_VARIABLE_VALUE](state, { key, value }) {
Object.assign(state.variables[key], {
...state.variables[key],
value,
});
},
[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](state, { variable, label, data = [] }) {
const values = optionsFromSeriesData({ label, data });
// Add new options with assign to ensure Vue reactivity
Object.assign(variable.options, { values });
},
};
......@@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({
* @param {Object} custom variable option
* @returns {Object} normalized custom variable options
*/
const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({
const normalizeVariableValues = ({ default: defaultOpt = false, text, value }) => ({
default: defaultOpt,
text: text || value,
value,
......@@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val
* The default value is the option with default set to true or the first option
* if none of the options have default prop true.
*
* @param {Object} advVariable advance custom variable
* @param {Object} advVariable advanced custom variable
* @returns {Object}
*/
const customAdvancedVariableParser = advVariable => {
const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions);
const defaultOpt = options.find(opt => opt.default === true) || options[0];
const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues);
const defaultValue = values.find(opt => opt.default === true) || values[0];
return {
type: VARIABLE_TYPES.custom,
label: advVariable.label,
value: defaultOpt?.value,
options,
value: defaultValue?.value,
options: {
values,
},
};
};
......@@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => {
* @param {String} opt option from simple custom variable
* @returns {Object}
*/
const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
export const parseSimpleCustomValues = opt => ({ text: opt, value: opt });
/**
* Custom simple variables are rendered as dropdown elements in the dashboard
......@@ -95,12 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
* @returns {Object}
*/
const customSimpleVariableParser = simpleVar => {
const options = (simpleVar || []).map(parseSimpleCustomOptions);
const values = (simpleVar || []).map(parseSimpleCustomValues);
return {
type: VARIABLE_TYPES.custom,
value: options[0].value,
value: values[0].value,
label: null,
options: options.map(normalizeCustomVariableOptions),
options: {
values: values.map(normalizeVariableValues),
},
};
};
const metricLabelValuesVariableParser = variable => {
const { label, options = {} } = variable;
return {
type: VARIABLE_TYPES.metric_label_values,
value: null,
label,
options: {
prometheusEndpointPath: options.prometheus_endpoint_path || '',
label: options.label || null,
values: [], // values are initially empty
},
};
};
......@@ -123,14 +141,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar);
* @return {Function} parser method
*/
const getVariableParser = variable => {
if (isSimpleCustomVariable(variable)) {
if (isString(variable)) {
return textSimpleVariableParser;
} else if (isSimpleCustomVariable(variable)) {
return customSimpleVariableParser;
} else if (variable.type === VARIABLE_TYPES.custom) {
return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.text) {
return textAdvancedVariableParser;
} else if (isString(variable)) {
return textSimpleVariableParser;
} else if (variable.type === VARIABLE_TYPES.custom) {
return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.metric_label_values) {
return metricLabelValuesVariableParser;
}
return () => null;
};
......@@ -200,4 +220,67 @@ export const mergeURLVariables = (varsFromYML = {}) => {
return variables;
};
/**
* Converts series data to options that can be added to a
* variable. Series data is returned from the Prometheus API
* `/api/v1/series`.
*
* Finds a `label` in the series data, so it can be used as
* a filter.
*
* For example, for the arguments:
*
* {
* "label": "job"
* "data" : [
* {
* "__name__" : "up",
* "job" : "prometheus",
* "instance" : "localhost:9090"
* },
* {
* "__name__" : "up",
* "job" : "node",
* "instance" : "localhost:9091"
* },
* {
* "__name__" : "process_start_time_seconds",
* "job" : "prometheus",
* "instance" : "localhost:9090"
* }
* ]
* }
*
* It returns all the different "job" values:
*
* [
* {
* "label": "node",
* "value": "node"
* },
* {
* "label": "prometheus",
* "value": "prometheus"
* }
* ]
*
* @param {options} options object
* @param {options.seriesLabel} name of the searched series label
* @param {options.data} series data from the series API
* @return {array} Options objects with the shape `{ label, value }`
*
* @see https://prometheus.io/docs/prometheus/latest/querying/api/#finding-series-by-label-matchers
*/
export const optionsFromSeriesData = ({ label, data = [] }) => {
const optionsSet = data.reduce((set, seriesObject) => {
// Use `new Set` to deduplicate options
if (seriesObject[label]) {
set.add(seriesObject[label]);
}
return set;
}, new Set());
return [...optionsSet].map(parseSimpleCustomValues);
};
export default {};
---
title: Fetch metrics dashboard templating variable options using a Prometheus query
merge_request: 34607
author:
type: added
......@@ -14308,6 +14308,9 @@ msgstr ""
msgid "Metrics|Refresh dashboard"
msgstr ""
msgid "Metrics|Select a value"
msgstr ""
msgid "Metrics|Star dashboard"
msgstr ""
......@@ -14335,6 +14338,9 @@ msgstr ""
msgid "Metrics|There was an error getting environments information."
msgstr ""
msgid "Metrics|There was an error getting options for variable \"%{name}\"."
msgstr ""
msgid "Metrics|There was an error trying to validate your query"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
import DropdownField from '~/monitoring/components/variables/dropdown_field.vue';
describe('Custom variable component', () => {
let wrapper;
const propsData = {
const defaultProps = {
name: 'env',
label: 'Select environment',
value: 'Production',
options: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }],
options: {
values: [{ text: 'Production', value: 'prod' }, { text: 'Canary', value: 'canary' }],
},
};
const createShallowWrapper = () => {
wrapper = shallowMount(CustomVariable, {
propsData,
const createShallowWrapper = props => {
wrapper = shallowMount(DropdownField, {
propsData: {
...defaultProps,
...props,
},
});
};
......@@ -22,19 +29,25 @@ describe('Custom variable component', () => {
it('renders dropdown element when all necessary props are passed', () => {
createShallowWrapper();
expect(findDropdown()).toExist();
expect(findDropdown().exists()).toBe(true);
});
it('renders dropdown element with a text', () => {
createShallowWrapper();
expect(findDropdown().attributes('text')).toBe(propsData.value);
expect(findDropdown().attributes('text')).toBe(defaultProps.value);
});
it('renders all the dropdown items', () => {
createShallowWrapper();
expect(findDropdownItems()).toHaveLength(propsData.options.length);
expect(findDropdownItems()).toHaveLength(defaultProps.options.values.length);
});
it('renders dropdown when values are missing', () => {
createShallowWrapper({ options: {} });
expect(findDropdown().exists()).toBe(true);
});
it('changing dropdown items triggers update', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlFormInput } from '@gitlab/ui';
import TextVariable from '~/monitoring/components/variables/text_variable.vue';
import TextField from '~/monitoring/components/variables/text_field.vue';
describe('Text variable component', () => {
let wrapper;
......@@ -10,7 +10,7 @@ describe('Text variable component', () => {
value: 'test-pod',
};
const createShallowWrapper = () => {
wrapper = shallowMount(TextVariable, {
wrapper = shallowMount(TextField, {
propsData,
});
};
......
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VariablesSection from '~/monitoring/components/variables_section.vue';
import CustomVariable from '~/monitoring/components/variables/custom_variable.vue';
import TextVariable from '~/monitoring/components/variables/text_variable.vue';
import DropdownField from '~/monitoring/components/variables/dropdown_field.vue';
import TextField from '~/monitoring/components/variables/text_field.vue';
import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
import { createStore } from '~/monitoring/stores';
import { convertVariablesForURL } from '~/monitoring/utils';
......@@ -21,6 +21,7 @@ describe('Metrics dashboard/variables section component', () => {
label1: mockTemplatingDataResponses.simpleText.simpleText,
label2: mockTemplatingDataResponses.advText.advText,
label3: mockTemplatingDataResponses.simpleCustom.simpleCustom,
label4: mockTemplatingDataResponses.metricLabelValues.simple,
};
const createShallowWrapper = () => {
......@@ -29,8 +30,8 @@ describe('Metrics dashboard/variables section component', () => {
});
};
const findTextInput = () => wrapper.findAll(TextVariable);
const findCustomInput = () => wrapper.findAll(CustomVariable);
const findTextInputs = () => wrapper.findAll(TextField);
const findCustomInputs = () => wrapper.findAll(DropdownField);
beforeEach(() => {
store = createStore();
......@@ -40,20 +41,30 @@ describe('Metrics dashboard/variables section component', () => {
it('does not show the variables section', () => {
createShallowWrapper();
const allInputs = findTextInput().length + findCustomInput().length;
const allInputs = findTextInputs().length + findCustomInputs().length;
expect(allInputs).toBe(0);
});
it('shows the variables section', () => {
createShallowWrapper();
store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables);
describe('when variables are set', () => {
beforeEach(() => {
createShallowWrapper();
store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables);
return wrapper.vm.$nextTick;
});
return wrapper.vm.$nextTick(() => {
const allInputs = findTextInput().length + findCustomInput().length;
it('shows the variables section', () => {
const allInputs = findTextInputs().length + findCustomInputs().length;
expect(allInputs).toBe(Object.keys(sampleVariables).length);
});
it('shows the right custom variable inputs', () => {
const customInputs = findCustomInputs();
expect(customInputs.at(0).props('name')).toBe('label3');
expect(customInputs.at(1).props('name')).toBe('label4');
});
});
describe('when changing the variable inputs', () => {
......@@ -79,7 +90,7 @@ describe('Metrics dashboard/variables section component', () => {
});
it('merges the url params and refreshes the dashboard when a text-based variables inputs are updated', () => {
const firstInput = findTextInput().at(0);
const firstInput = findTextInputs().at(0);
firstInput.vm.$emit('onUpdate', 'label1', 'test');
......@@ -94,7 +105,7 @@ describe('Metrics dashboard/variables section component', () => {
});
it('merges the url params and refreshes the dashboard when a custom-based variables inputs are updated', () => {
const firstInput = findCustomInput().at(0);
const firstInput = findCustomInputs().at(0);
firstInput.vm.$emit('onUpdate', 'label1', 'test');
......@@ -109,7 +120,7 @@ describe('Metrics dashboard/variables section component', () => {
});
it('does not merge the url params and refreshes the dashboard if the value entered is not different that is what currently stored', () => {
const firstInput = findTextInput().at(0);
const firstInput = findTextInputs().at(0);
firstInput.vm.$emit('onUpdate', 'label1', 'Simple text');
......
......@@ -717,6 +717,17 @@ const templatingVariableTypes = {
},
},
},
metricLabelValues: {
simple: {
label: 'Metric Label Values',
type: 'metric_label_values',
options: {
prometheus_endpoint_path: '/series',
series_selector: 'backend:haproxy_backend_availability:ratio{env="{{env}}"}',
label: 'backend',
},
},
},
};
const generateMockTemplatingData = data => {
......@@ -754,23 +765,25 @@ const responseForSimpleCustomVariable = {
simpleCustom: {
label: 'simpleCustom',
value: 'value1',
options: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: false,
text: 'value2',
value: 'value2',
},
{
default: false,
text: 'value3',
value: 'value3',
},
],
options: {
values: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: false,
text: 'value2',
value: 'value2',
},
{
default: false,
text: 'value3',
value: 'value3',
},
],
},
type: 'custom',
},
};
......@@ -778,7 +791,9 @@ const responseForSimpleCustomVariable = {
const responseForAdvancedCustomVariableWithoutOptions = {
advCustomWithoutOpts: {
label: 'advCustomWithoutOpts',
options: [],
options: {
values: [],
},
type: 'custom',
},
};
......@@ -787,18 +802,20 @@ const responseForAdvancedCustomVariableWithoutLabel = {
advCustomWithoutLabel: {
label: 'advCustomWithoutLabel',
value: 'value2',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
options: {
values: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
},
type: 'custom',
},
};
......@@ -807,39 +824,56 @@ const responseForAdvancedCustomVariableWithoutOptText = {
advCustomWithoutOptText: {
label: 'Options without text',
value: 'value2',
options: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: true,
text: 'value2',
value: 'value2',
},
],
options: {
values: [
{
default: false,
text: 'value1',
value: 'value1',
},
{
default: true,
text: 'value2',
value: 'value2',
},
],
},
type: 'custom',
},
};
const responseForMetricLabelValues = {
simple: {
label: 'Metric Label Values',
type: 'metric_label_values',
value: null,
options: {
prometheusEndpointPath: '/series',
label: 'backend',
values: [],
},
},
};
const responseForAdvancedCustomVariable = {
...responseForSimpleCustomVariable,
advCustomNormal: {
label: 'Advanced Var',
value: 'value2',
options: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
options: {
values: [
{
default: false,
text: 'Var 1 Option 1',
value: 'value1',
},
{
default: true,
text: 'Var 1 Option 2',
value: 'value2',
},
],
},
type: 'custom',
},
};
......@@ -873,6 +907,9 @@ export const mockTemplatingData = {
simpleCustom: templatingVariableTypes.custom.simple,
advCustomNormal: templatingVariableTypes.custom.advanced.normal,
}),
metricLabelValues: generateMockTemplatingData({
simple: templatingVariableTypes.metricLabelValues.simple,
}),
allVariableTypes: generateMockTemplatingData({
simpleText: templatingVariableTypes.text.simple,
advText: templatingVariableTypes.text.advanced,
......@@ -893,4 +930,5 @@ export const mockTemplatingDataResponses = {
advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText,
simpleAndAdv: responseForAdvancedCustomVariable,
allVariableTypes: responsesForAllVariableTypes,
metricLabelValues: responseForMetricLabelValues,
};
......@@ -29,6 +29,7 @@ import {
toggleStarredValue,
duplicateSystemDashboard,
updateVariablesAndFetchData,
fetchVariableMetricLabelValues,
} from '~/monitoring/stores/actions';
import {
gqClient,
......@@ -384,14 +385,22 @@ describe('Monitoring store actions', () => {
value: 0,
},
);
expect(dispatch).toHaveBeenCalledTimes(1);
expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
defaultQueryParams: {
start_time: expect.any(String),
end_time: expect.any(String),
step: expect.any(Number),
},
});
expect(createFlash).not.toHaveBeenCalled();
done();
})
.catch(done.fail);
});
it('dispatches fetchPrometheusMetric for each panel query', done => {
state.dashboard.panelGroups = convertObjectPropsToCamelCase(
metricsDashboardResponse.dashboard.panel_groups,
......@@ -434,21 +443,27 @@ describe('Monitoring store actions', () => {
const metric = state.dashboard.panelGroups[0].panels[0].metrics[0];
dispatch.mockResolvedValueOnce(); // fetchDeploymentsData
dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues
// Mock having one out of four metrics failing
dispatch.mockRejectedValueOnce(new Error('Error fetching this metric'));
dispatch.mockResolvedValue();
fetchDashboardData({ state, commit, dispatch })
.then(() => {
expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 1); // plus 1 for deployments
const defaultQueryParams = {
start_time: expect.any(String),
end_time: expect.any(String),
step: expect.any(Number),
};
expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 2); // plus 1 for deployments
expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
expect(dispatch).toHaveBeenCalledWith('fetchVariableMetricLabelValues', {
defaultQueryParams,
});
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric,
defaultQueryParams: {
start_time: expect.any(String),
end_time: expect.any(String),
step: expect.any(Number),
},
defaultQueryParams,
});
expect(createFlash).toHaveBeenCalledTimes(1);
......@@ -1116,14 +1131,14 @@ describe('Monitoring store actions', () => {
// Variables manipulation
describe('updateVariablesAndFetchData', () => {
it('should commit UPDATE_VARIABLES mutation and fetch data', done => {
it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', done => {
testAction(
updateVariablesAndFetchData,
{ pod: 'POD' },
state,
[
{
type: types.UPDATE_VARIABLES,
type: types.UPDATE_VARIABLE_VALUE,
payload: { pod: 'POD' },
},
],
......@@ -1136,4 +1151,72 @@ describe('Monitoring store actions', () => {
);
});
});
describe('fetchVariableMetricLabelValues', () => {
const variable = {
type: 'metric_label_values',
options: {
prometheusEndpointPath: '/series',
label: 'job',
},
};
const defaultQueryParams = {
start_time: '2019-08-06T12:40:02.184Z',
end_time: '2019-08-06T20:40:02.184Z',
};
beforeEach(() => {
state = {
...state,
timeRange: defaultTimeRange,
variables: {
label1: variable,
},
};
});
it('should commit UPDATE_VARIABLE_METRIC_LABEL_VALUES mutation and fetch data', () => {
const data = [
{
__name__: 'up',
job: 'prometheus',
},
{
__name__: 'up',
job: 'POD',
},
];
mock.onGet('/series').reply(200, {
status: 'success',
data,
});
return testAction(
fetchVariableMetricLabelValues,
{ defaultQueryParams },
state,
[
{
type: types.UPDATE_VARIABLE_METRIC_LABEL_VALUES,
payload: { variable, label: 'job', data },
},
],
[],
);
});
it('should notify the user that dynamic options were not loaded', () => {
mock.onGet('/series').reply(500);
return testAction(fetchVariableMetricLabelValues, { defaultQueryParams }, state, [], []).then(
() => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith(
expect.stringContaining('error getting options for variable "label1"'),
);
},
);
});
});
});
......@@ -441,16 +441,57 @@ describe('Monitoring mutations', () => {
});
});
describe('UPDATE_VARIABLES', () => {
describe('UPDATE_VARIABLE_VALUE', () => {
afterEach(() => {
mutations[types.SET_VARIABLES](stateCopy, {});
});
it('updates only the value of the variable in variables', () => {
mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } });
mutations[types.UPDATE_VARIABLES](stateCopy, { key: 'environment', value: 'new prod' });
mutations[types.UPDATE_VARIABLE_VALUE](stateCopy, { key: 'environment', value: 'new prod' });
expect(stateCopy.variables).toEqual({ environment: { value: 'new prod', type: 'text' } });
});
});
describe('UPDATE_VARIABLE_METRIC_LABEL_VALUES', () => {
it('updates options in a variable', () => {
const data = [
{
__name__: 'up',
job: 'prometheus',
env: 'prd',
},
{
__name__: 'up',
job: 'prometheus',
env: 'stg',
},
{
__name__: 'up',
job: 'node',
env: 'prod',
},
{
__name__: 'up',
job: 'node',
env: 'stg',
},
];
const variable = {
options: {},
};
mutations[types.UPDATE_VARIABLE_METRIC_LABEL_VALUES](stateCopy, {
variable,
label: 'job',
data,
});
expect(variable.options).toEqual({
values: [{ text: 'prometheus', value: 'prometheus' }, { text: 'node', value: 'node' }],
});
});
});
});
import { parseTemplatingVariables, mergeURLVariables } from '~/monitoring/stores/variable_mapping';
import {
parseTemplatingVariables,
mergeURLVariables,
optionsFromSeriesData,
} from '~/monitoring/stores/variable_mapping';
import * as urlUtils from '~/lib/utils/url_utility';
import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data';
describe('parseTemplatingVariables', () => {
it.each`
case | input | expected
${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}}
${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}}
${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText}
${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText}
${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom}
${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts}
${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText}
${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}}
${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel}
${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv}
${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
`('$case', ({ input, expected }) => {
expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
describe('Monitoring variable mapping', () => {
describe('parseTemplatingVariables', () => {
it.each`
case | input | expected
${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
${'Returns empty object for empty dashboard input'} | ${{ dashboard: {} }} | ${{}}
${'Returns empty object for empty templating prop'} | ${mockTemplatingData.emptyTemplatingProp} | ${{}}
${'Returns empty object for empty variables prop'} | ${mockTemplatingData.emptyVariablesProp} | ${{}}
${'Returns parsed object for simple text variable'} | ${mockTemplatingData.simpleText} | ${mockTemplatingDataResponses.simpleText}
${'Returns parsed object for advanced text variable'} | ${mockTemplatingData.advText} | ${mockTemplatingDataResponses.advText}
${'Returns parsed object for simple custom variable'} | ${mockTemplatingData.simpleCustom} | ${mockTemplatingDataResponses.simpleCustom}
${'Returns parsed object for advanced custom variable without options'} | ${mockTemplatingData.advCustomWithoutOpts} | ${mockTemplatingDataResponses.advCustomWithoutOpts}
${'Returns parsed object for advanced custom variable for option without text'} | ${mockTemplatingData.advCustomWithoutOptText} | ${mockTemplatingDataResponses.advCustomWithoutOptText}
${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}}
${'Returns parsed object for advanced custom variable without label'} | ${mockTemplatingData.advCustomWithoutLabel} | ${mockTemplatingDataResponses.advCustomWithoutLabel}
${'Returns parsed object for simple and advanced custom variables'} | ${mockTemplatingData.simpleAndAdv} | ${mockTemplatingDataResponses.simpleAndAdv}
${'Returns parsed object for metricLabelValues'} | ${mockTemplatingData.metricLabelValues} | ${mockTemplatingDataResponses.metricLabelValues}
${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
`('$case', ({ input, expected }) => {
expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
});
});
});
describe('mergeURLVariables', () => {
beforeEach(() => {
jest.spyOn(urlUtils, 'queryToObject');
});
describe('mergeURLVariables', () => {
beforeEach(() => {
jest.spyOn(urlUtils, 'queryToObject');
});
afterEach(() => {
urlUtils.queryToObject.mockRestore();
});
afterEach(() => {
urlUtils.queryToObject.mockRestore();
});
it('returns empty object if variables are not defined in yml or URL', () => {
urlUtils.queryToObject.mockReturnValueOnce({});
it('returns empty object if variables are not defined in yml or URL', () => {
urlUtils.queryToObject.mockReturnValueOnce({});
expect(mergeURLVariables({})).toEqual({});
});
expect(mergeURLVariables({})).toEqual({});
});
it('returns empty object if variables are defined in URL but not in yml', () => {
urlUtils.queryToObject.mockReturnValueOnce({
'var-env': 'one',
'var-instance': 'localhost',
});
it('returns empty object if variables are defined in URL but not in yml', () => {
urlUtils.queryToObject.mockReturnValueOnce({
'var-env': 'one',
'var-instance': 'localhost',
expect(mergeURLVariables({})).toEqual({});
});
expect(mergeURLVariables({})).toEqual({});
});
it('returns yml variables if variables defined in yml but not in the URL', () => {
urlUtils.queryToObject.mockReturnValueOnce({});
it('returns yml variables if variables defined in yml but not in the URL', () => {
urlUtils.queryToObject.mockReturnValueOnce({});
const params = {
env: 'one',
instance: 'localhost',
};
const params = {
env: 'one',
instance: 'localhost',
};
expect(mergeURLVariables(params)).toEqual(params);
});
expect(mergeURLVariables(params)).toEqual(params);
});
it('returns yml variables if variables defined in URL do not match with yml variables', () => {
const urlParams = {
'var-env': 'one',
'var-instance': 'localhost',
};
const ymlParams = {
pod: { value: 'one' },
service: { value: 'database' },
};
urlUtils.queryToObject.mockReturnValueOnce(urlParams);
expect(mergeURLVariables(ymlParams)).toEqual(ymlParams);
});
it('returns merged yml and URL variables if there is some match', () => {
const urlParams = {
'var-env': 'one',
'var-instance': 'localhost:8080',
};
const ymlParams = {
instance: { value: 'localhost' },
service: { value: 'database' },
};
const merged = {
instance: { value: 'localhost:8080' },
service: { value: 'database' },
};
urlUtils.queryToObject.mockReturnValueOnce(urlParams);
it('returns yml variables if variables defined in URL do not match with yml variables', () => {
const urlParams = {
'var-env': 'one',
'var-instance': 'localhost',
};
const ymlParams = {
pod: { value: 'one' },
service: { value: 'database' },
};
urlUtils.queryToObject.mockReturnValueOnce(urlParams);
expect(mergeURLVariables(ymlParams)).toEqual(ymlParams);
expect(mergeURLVariables(ymlParams)).toEqual(merged);
});
});
it('returns merged yml and URL variables if there is some match', () => {
const urlParams = {
'var-env': 'one',
'var-instance': 'localhost:8080',
};
const ymlParams = {
instance: { value: 'localhost' },
service: { value: 'database' },
};
describe('optionsFromSeriesData', () => {
it('fetches the label values from missing data', () => {
expect(optionsFromSeriesData({ label: 'job' })).toEqual([]);
});
const merged = {
instance: { value: 'localhost:8080' },
service: { value: 'database' },
};
it('fetches the label values from a simple series', () => {
const data = [
{
__name__: 'up',
job: 'job1',
},
{
__name__: 'up',
job: 'job2',
},
];
expect(optionsFromSeriesData({ label: 'job', data })).toEqual([
{ text: 'job1', value: 'job1' },
{ text: 'job2', value: 'job2' },
]);
});
urlUtils.queryToObject.mockReturnValueOnce(urlParams);
it('fetches the label values from multiple series', () => {
const data = [
{
__name__: 'up',
job: 'job1',
instance: 'host1',
},
{
__name__: 'up',
job: 'job2',
instance: 'host1',
},
{
__name__: 'up',
job: 'job1',
instance: 'host2',
},
{
__name__: 'up',
job: 'job2',
instance: 'host2',
},
];
expect(optionsFromSeriesData({ label: '__name__', data })).toEqual([
{ text: 'up', value: 'up' },
]);
expect(optionsFromSeriesData({ label: 'job', data })).toEqual([
{ text: 'job1', value: 'job1' },
{ text: 'job2', value: 'job2' },
]);
expect(optionsFromSeriesData({ label: 'instance', data })).toEqual([
{ text: 'host1', value: 'host1' },
{ text: 'host2', value: 'host2' },
]);
});
expect(mergeURLVariables(ymlParams)).toEqual(merged);
it('fetches the label values from a series with missing values', () => {
const data = [
{
__name__: 'up',
job: 'job1',
},
{
__name__: 'up',
job: 'job2',
},
{
__name__: 'up',
},
];
expect(optionsFromSeriesData({ label: 'job', data })).toEqual([
{ text: 'job1', value: 'job1' },
{ text: 'job2', value: 'job2' },
]);
});
});
});
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