Commit ac722682 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '214539-fe-fetch-dynamic-variable-options' into 'master'

Fetch variable options from the Prometheus API to populate dropdown

See merge request gitlab-org/gitlab!34607
parents 9ffbb6ac 2c4dbd41
...@@ -22,13 +22,13 @@ export default { ...@@ -22,13 +22,13 @@ export default {
default: '', default: '',
}, },
options: { options: {
type: Array, type: Object,
required: true, required: true,
}, },
}, },
computed: { computed: {
defaultText() { text() {
const selectedOpt = this.options.find(opt => opt.value === this.value); const selectedOpt = this.options.values?.find(opt => opt.value === this.value);
return selectedOpt?.text || this.value; return selectedOpt?.text || this.value;
}, },
}, },
...@@ -41,10 +41,13 @@ export default { ...@@ -41,10 +41,13 @@ export default {
</script> </script>
<template> <template>
<gl-form-group :label="label"> <gl-form-group :label="label">
<gl-dropdown toggle-class="dropdown-menu-toggle" :text="defaultText"> <gl-dropdown toggle-class="dropdown-menu-toggle" :text="text || s__('Metrics|Select a value')">
<gl-dropdown-item v-for="(opt, key) in options" :key="key" @click="onUpdate(opt.value)">{{ <gl-dropdown-item
opt.text v-for="val in options.values"
}}</gl-dropdown-item> :key="val.value"
@click="onUpdate(val.value)"
>{{ val.text }}</gl-dropdown-item
>
</gl-dropdown> </gl-dropdown>
</gl-form-group> </gl-form-group>
</template> </template>
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import CustomVariable from './variables/custom_variable.vue'; import DropdownField from './variables/dropdown_field.vue';
import TextVariable from './variables/text_variable.vue'; import TextField from './variables/text_field.vue';
import { setCustomVariablesFromUrl } from '../utils'; import { setCustomVariablesFromUrl } from '../utils';
import { VARIABLE_TYPES } from '../constants';
export default { export default {
components: { components: {
CustomVariable, DropdownField,
TextVariable, TextField,
}, },
computed: { computed: {
...mapState('monitoringDashboard', ['variables']), ...mapState('monitoringDashboard', ['variables']),
...@@ -27,12 +28,11 @@ export default { ...@@ -27,12 +28,11 @@ export default {
setCustomVariablesFromUrl(this.variables); setCustomVariablesFromUrl(this.variables);
} }
}, },
variableComponent(type) { variableField(type) {
const types = { if (type === VARIABLE_TYPES.custom || type === VARIABLE_TYPES.metric_label_values) {
text: TextVariable, return DropdownField;
custom: CustomVariable, }
}; return TextField;
return types[type] || TextVariable;
}, },
}, },
}; };
...@@ -41,7 +41,7 @@ export default { ...@@ -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 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"> <div v-for="(variable, key) in variables" :key="key" class="mb-1 pr-2 d-flex d-sm-block">
<component <component
:is="variableComponent(variable.type)" :is="variableField(variable.type)"
class="mb-0 flex-grow-1" class="mb-0 flex-grow-1"
:label="variable.label" :label="variable.label"
:value="variable.value" :value="variable.value"
......
...@@ -230,6 +230,7 @@ export const OPERATORS = { ...@@ -230,6 +230,7 @@ export const OPERATORS = {
export const VARIABLE_TYPES = { export const VARIABLE_TYPES = {
custom: 'custom', custom: 'custom',
text: 'text', text: 'text',
metric_label_values: 'metric_label_values',
}; };
/** /**
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
PROMETHEUS_TIMEOUT, PROMETHEUS_TIMEOUT,
ENVIRONMENT_AVAILABLE_STATE, ENVIRONMENT_AVAILABLE_STATE,
DEFAULT_DASHBOARD_PATH, DEFAULT_DASHBOARD_PATH,
VARIABLE_TYPES,
} from '../constants'; } from '../constants';
function prometheusMetricQueryParams(timeRange) { function prometheusMetricQueryParams(timeRange) {
...@@ -191,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { ...@@ -191,8 +192,12 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => {
return Promise.reject(); 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); const defaultQueryParams = prometheusMetricQueryParams(state.timeRange);
dispatch('fetchVariableMetricLabelValues', { defaultQueryParams });
const promises = []; const promises = [];
state.dashboard.panelGroups.forEach(group => { state.dashboard.panelGroups.forEach(group => {
group.panels.forEach(panel => { group.panels.forEach(panel => {
...@@ -466,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => { ...@@ -466,10 +471,41 @@ export const duplicateSystemDashboard = ({ state }, payload) => {
// Variables manipulation // Variables manipulation
export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => { export const updateVariablesAndFetchData = ({ commit, dispatch }, updatedVariable) => {
commit(types.UPDATE_VARIABLES, updatedVariable); commit(types.UPDATE_VARIABLE_VALUE, updatedVariable);
return dispatch('fetchDashboardData'); 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 // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
...@@ -3,7 +3,8 @@ export const REQUEST_METRICS_DASHBOARD = 'REQUEST_METRICS_DASHBOARD'; ...@@ -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_SUCCESS = 'RECEIVE_METRICS_DASHBOARD_SUCCESS';
export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE'; export const RECEIVE_METRICS_DASHBOARD_FAILURE = 'RECEIVE_METRICS_DASHBOARD_FAILURE';
export const SET_VARIABLES = 'SET_VARIABLES'; 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 REQUEST_DASHBOARD_STARRING = 'REQUEST_DASHBOARD_STARRING';
export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS'; export const RECEIVE_DASHBOARD_STARRING_SUCCESS = 'RECEIVE_DASHBOARD_STARRING_SUCCESS';
......
...@@ -2,9 +2,10 @@ import Vue from 'vue'; ...@@ -2,9 +2,10 @@ import Vue from 'vue';
import { pick } from 'lodash'; import { pick } from 'lodash';
import * as types from './mutation_types'; import * as types from './mutation_types';
import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils';
import { endpointKeys, initialStateKeys, metricStates } from '../constants'; 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 * Locate and return a metric in the dashboard by its id
...@@ -205,10 +206,16 @@ export default { ...@@ -205,10 +206,16 @@ export default {
[types.SET_VARIABLES](state, variables) { [types.SET_VARIABLES](state, variables) {
state.variables = variables; state.variables = variables;
}, },
[types.UPDATE_VARIABLES](state, updatedVariable) { [types.UPDATE_VARIABLE_VALUE](state, { key, value }) {
Object.assign(state.variables[updatedVariable.key], { Object.assign(state.variables[key], {
...state.variables[updatedVariable.key], ...state.variables[key],
value: updatedVariable.value, 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 => ({ ...@@ -46,7 +46,7 @@ const textAdvancedVariableParser = advTextVar => ({
* @param {Object} custom variable option * @param {Object} custom variable option
* @returns {Object} normalized custom variable options * @returns {Object} normalized custom variable options
*/ */
const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, value }) => ({ const normalizeVariableValues = ({ default: defaultOpt = false, text, value }) => ({
default: defaultOpt, default: defaultOpt,
text: text || value, text: text || value,
value, value,
...@@ -59,17 +59,19 @@ const normalizeCustomVariableOptions = ({ default: defaultOpt = false, text, val ...@@ -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 * The default value is the option with default set to true or the first option
* if none of the options have default prop true. * if none of the options have default prop true.
* *
* @param {Object} advVariable advance custom variable * @param {Object} advVariable advanced custom variable
* @returns {Object} * @returns {Object}
*/ */
const customAdvancedVariableParser = advVariable => { const customAdvancedVariableParser = advVariable => {
const options = (advVariable?.options?.values ?? []).map(normalizeCustomVariableOptions); const values = (advVariable?.options?.values ?? []).map(normalizeVariableValues);
const defaultOpt = options.find(opt => opt.default === true) || options[0]; const defaultValue = values.find(opt => opt.default === true) || values[0];
return { return {
type: VARIABLE_TYPES.custom, type: VARIABLE_TYPES.custom,
label: advVariable.label, label: advVariable.label,
value: defaultOpt?.value, value: defaultValue?.value,
options, options: {
values,
},
}; };
}; };
...@@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => { ...@@ -80,7 +82,7 @@ const customAdvancedVariableParser = advVariable => {
* @param {String} opt option from simple custom variable * @param {String} opt option from simple custom variable
* @returns {Object} * @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 * Custom simple variables are rendered as dropdown elements in the dashboard
...@@ -95,12 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt }); ...@@ -95,12 +97,28 @@ const parseSimpleCustomOptions = opt => ({ text: opt, value: opt });
* @returns {Object} * @returns {Object}
*/ */
const customSimpleVariableParser = simpleVar => { const customSimpleVariableParser = simpleVar => {
const options = (simpleVar || []).map(parseSimpleCustomOptions); const values = (simpleVar || []).map(parseSimpleCustomValues);
return { return {
type: VARIABLE_TYPES.custom, type: VARIABLE_TYPES.custom,
value: options[0].value, value: values[0].value,
label: null, 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); ...@@ -123,14 +141,16 @@ const isSimpleCustomVariable = customVar => Array.isArray(customVar);
* @return {Function} parser method * @return {Function} parser method
*/ */
const getVariableParser = variable => { const getVariableParser = variable => {
if (isSimpleCustomVariable(variable)) { if (isString(variable)) {
return textSimpleVariableParser;
} else if (isSimpleCustomVariable(variable)) {
return customSimpleVariableParser; return customSimpleVariableParser;
} else if (variable.type === VARIABLE_TYPES.custom) {
return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.text) { } else if (variable.type === VARIABLE_TYPES.text) {
return textAdvancedVariableParser; return textAdvancedVariableParser;
} else if (isString(variable)) { } else if (variable.type === VARIABLE_TYPES.custom) {
return textSimpleVariableParser; return customAdvancedVariableParser;
} else if (variable.type === VARIABLE_TYPES.metric_label_values) {
return metricLabelValuesVariableParser;
} }
return () => null; return () => null;
}; };
...@@ -200,4 +220,67 @@ export const mergeURLVariables = (varsFromYML = {}) => { ...@@ -200,4 +220,67 @@ export const mergeURLVariables = (varsFromYML = {}) => {
return variables; 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 {}; export default {};
---
title: Fetch metrics dashboard templating variable options using a Prometheus query
merge_request: 34607
author:
type: added
...@@ -14308,6 +14308,9 @@ msgstr "" ...@@ -14308,6 +14308,9 @@ msgstr ""
msgid "Metrics|Refresh dashboard" msgid "Metrics|Refresh dashboard"
msgstr "" msgstr ""
msgid "Metrics|Select a value"
msgstr ""
msgid "Metrics|Star dashboard" msgid "Metrics|Star dashboard"
msgstr "" msgstr ""
...@@ -14335,6 +14338,9 @@ msgstr "" ...@@ -14335,6 +14338,9 @@ msgstr ""
msgid "Metrics|There was an error getting environments information." msgid "Metrics|There was an error getting environments information."
msgstr "" msgstr ""
msgid "Metrics|There was an error getting options for variable \"%{name}\"."
msgstr ""
msgid "Metrics|There was an error trying to validate your query" msgid "Metrics|There was an error trying to validate your query"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; 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', () => { describe('Custom variable component', () => {
let wrapper; let wrapper;
const propsData = {
const defaultProps = {
name: 'env', name: 'env',
label: 'Select environment', label: 'Select environment',
value: 'Production', 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, { const createShallowWrapper = props => {
propsData, wrapper = shallowMount(DropdownField, {
propsData: {
...defaultProps,
...props,
},
}); });
}; };
...@@ -22,19 +29,25 @@ describe('Custom variable component', () => { ...@@ -22,19 +29,25 @@ describe('Custom variable component', () => {
it('renders dropdown element when all necessary props are passed', () => { it('renders dropdown element when all necessary props are passed', () => {
createShallowWrapper(); createShallowWrapper();
expect(findDropdown()).toExist(); expect(findDropdown().exists()).toBe(true);
}); });
it('renders dropdown element with a text', () => { it('renders dropdown element with a text', () => {
createShallowWrapper(); createShallowWrapper();
expect(findDropdown().attributes('text')).toBe(propsData.value); expect(findDropdown().attributes('text')).toBe(defaultProps.value);
}); });
it('renders all the dropdown items', () => { it('renders all the dropdown items', () => {
createShallowWrapper(); 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', () => { it('changing dropdown items triggers update', () => {
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlFormInput } from '@gitlab/ui'; 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', () => { describe('Text variable component', () => {
let wrapper; let wrapper;
...@@ -10,7 +10,7 @@ describe('Text variable component', () => { ...@@ -10,7 +10,7 @@ describe('Text variable component', () => {
value: 'test-pod', value: 'test-pod',
}; };
const createShallowWrapper = () => { const createShallowWrapper = () => {
wrapper = shallowMount(TextVariable, { wrapper = shallowMount(TextField, {
propsData, propsData,
}); });
}; };
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import VariablesSection from '~/monitoring/components/variables_section.vue'; import VariablesSection from '~/monitoring/components/variables_section.vue';
import CustomVariable from '~/monitoring/components/variables/custom_variable.vue'; import DropdownField from '~/monitoring/components/variables/dropdown_field.vue';
import TextVariable from '~/monitoring/components/variables/text_variable.vue'; import TextField from '~/monitoring/components/variables/text_field.vue';
import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility'; import { updateHistory, mergeUrlParams } from '~/lib/utils/url_utility';
import { createStore } from '~/monitoring/stores'; import { createStore } from '~/monitoring/stores';
import { convertVariablesForURL } from '~/monitoring/utils'; import { convertVariablesForURL } from '~/monitoring/utils';
...@@ -21,6 +21,7 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -21,6 +21,7 @@ describe('Metrics dashboard/variables section component', () => {
label1: mockTemplatingDataResponses.simpleText.simpleText, label1: mockTemplatingDataResponses.simpleText.simpleText,
label2: mockTemplatingDataResponses.advText.advText, label2: mockTemplatingDataResponses.advText.advText,
label3: mockTemplatingDataResponses.simpleCustom.simpleCustom, label3: mockTemplatingDataResponses.simpleCustom.simpleCustom,
label4: mockTemplatingDataResponses.metricLabelValues.simple,
}; };
const createShallowWrapper = () => { const createShallowWrapper = () => {
...@@ -29,8 +30,8 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -29,8 +30,8 @@ describe('Metrics dashboard/variables section component', () => {
}); });
}; };
const findTextInput = () => wrapper.findAll(TextVariable); const findTextInputs = () => wrapper.findAll(TextField);
const findCustomInput = () => wrapper.findAll(CustomVariable); const findCustomInputs = () => wrapper.findAll(DropdownField);
beforeEach(() => { beforeEach(() => {
store = createStore(); store = createStore();
...@@ -40,20 +41,30 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -40,20 +41,30 @@ describe('Metrics dashboard/variables section component', () => {
it('does not show the variables section', () => { it('does not show the variables section', () => {
createShallowWrapper(); createShallowWrapper();
const allInputs = findTextInput().length + findCustomInput().length; const allInputs = findTextInputs().length + findCustomInputs().length;
expect(allInputs).toBe(0); expect(allInputs).toBe(0);
}); });
it('shows the variables section', () => { describe('when variables are set', () => {
beforeEach(() => {
createShallowWrapper(); createShallowWrapper();
store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables); store.commit(`monitoringDashboard/${types.SET_VARIABLES}`, sampleVariables);
return wrapper.vm.$nextTick;
});
return wrapper.vm.$nextTick(() => { it('shows the variables section', () => {
const allInputs = findTextInput().length + findCustomInput().length; const allInputs = findTextInputs().length + findCustomInputs().length;
expect(allInputs).toBe(Object.keys(sampleVariables).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', () => { describe('when changing the variable inputs', () => {
...@@ -79,7 +90,7 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -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', () => { 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'); firstInput.vm.$emit('onUpdate', 'label1', 'test');
...@@ -94,7 +105,7 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -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', () => { 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'); firstInput.vm.$emit('onUpdate', 'label1', 'test');
...@@ -109,7 +120,7 @@ describe('Metrics dashboard/variables section component', () => { ...@@ -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', () => { 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'); firstInput.vm.$emit('onUpdate', 'label1', 'Simple text');
......
...@@ -717,6 +717,17 @@ const templatingVariableTypes = { ...@@ -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 => { const generateMockTemplatingData = data => {
...@@ -754,7 +765,8 @@ const responseForSimpleCustomVariable = { ...@@ -754,7 +765,8 @@ const responseForSimpleCustomVariable = {
simpleCustom: { simpleCustom: {
label: 'simpleCustom', label: 'simpleCustom',
value: 'value1', value: 'value1',
options: [ options: {
values: [
{ {
default: false, default: false,
text: 'value1', text: 'value1',
...@@ -771,6 +783,7 @@ const responseForSimpleCustomVariable = { ...@@ -771,6 +783,7 @@ const responseForSimpleCustomVariable = {
value: 'value3', value: 'value3',
}, },
], ],
},
type: 'custom', type: 'custom',
}, },
}; };
...@@ -778,7 +791,9 @@ const responseForSimpleCustomVariable = { ...@@ -778,7 +791,9 @@ const responseForSimpleCustomVariable = {
const responseForAdvancedCustomVariableWithoutOptions = { const responseForAdvancedCustomVariableWithoutOptions = {
advCustomWithoutOpts: { advCustomWithoutOpts: {
label: 'advCustomWithoutOpts', label: 'advCustomWithoutOpts',
options: [], options: {
values: [],
},
type: 'custom', type: 'custom',
}, },
}; };
...@@ -787,7 +802,8 @@ const responseForAdvancedCustomVariableWithoutLabel = { ...@@ -787,7 +802,8 @@ const responseForAdvancedCustomVariableWithoutLabel = {
advCustomWithoutLabel: { advCustomWithoutLabel: {
label: 'advCustomWithoutLabel', label: 'advCustomWithoutLabel',
value: 'value2', value: 'value2',
options: [ options: {
values: [
{ {
default: false, default: false,
text: 'Var 1 Option 1', text: 'Var 1 Option 1',
...@@ -799,6 +815,7 @@ const responseForAdvancedCustomVariableWithoutLabel = { ...@@ -799,6 +815,7 @@ const responseForAdvancedCustomVariableWithoutLabel = {
value: 'value2', value: 'value2',
}, },
], ],
},
type: 'custom', type: 'custom',
}, },
}; };
...@@ -807,7 +824,8 @@ const responseForAdvancedCustomVariableWithoutOptText = { ...@@ -807,7 +824,8 @@ const responseForAdvancedCustomVariableWithoutOptText = {
advCustomWithoutOptText: { advCustomWithoutOptText: {
label: 'Options without text', label: 'Options without text',
value: 'value2', value: 'value2',
options: [ options: {
values: [
{ {
default: false, default: false,
text: 'value1', text: 'value1',
...@@ -819,16 +837,31 @@ const responseForAdvancedCustomVariableWithoutOptText = { ...@@ -819,16 +837,31 @@ const responseForAdvancedCustomVariableWithoutOptText = {
value: 'value2', value: 'value2',
}, },
], ],
},
type: 'custom', type: 'custom',
}, },
}; };
const responseForMetricLabelValues = {
simple: {
label: 'Metric Label Values',
type: 'metric_label_values',
value: null,
options: {
prometheusEndpointPath: '/series',
label: 'backend',
values: [],
},
},
};
const responseForAdvancedCustomVariable = { const responseForAdvancedCustomVariable = {
...responseForSimpleCustomVariable, ...responseForSimpleCustomVariable,
advCustomNormal: { advCustomNormal: {
label: 'Advanced Var', label: 'Advanced Var',
value: 'value2', value: 'value2',
options: [ options: {
values: [
{ {
default: false, default: false,
text: 'Var 1 Option 1', text: 'Var 1 Option 1',
...@@ -840,6 +873,7 @@ const responseForAdvancedCustomVariable = { ...@@ -840,6 +873,7 @@ const responseForAdvancedCustomVariable = {
value: 'value2', value: 'value2',
}, },
], ],
},
type: 'custom', type: 'custom',
}, },
}; };
...@@ -873,6 +907,9 @@ export const mockTemplatingData = { ...@@ -873,6 +907,9 @@ export const mockTemplatingData = {
simpleCustom: templatingVariableTypes.custom.simple, simpleCustom: templatingVariableTypes.custom.simple,
advCustomNormal: templatingVariableTypes.custom.advanced.normal, advCustomNormal: templatingVariableTypes.custom.advanced.normal,
}), }),
metricLabelValues: generateMockTemplatingData({
simple: templatingVariableTypes.metricLabelValues.simple,
}),
allVariableTypes: generateMockTemplatingData({ allVariableTypes: generateMockTemplatingData({
simpleText: templatingVariableTypes.text.simple, simpleText: templatingVariableTypes.text.simple,
advText: templatingVariableTypes.text.advanced, advText: templatingVariableTypes.text.advanced,
...@@ -893,4 +930,5 @@ export const mockTemplatingDataResponses = { ...@@ -893,4 +930,5 @@ export const mockTemplatingDataResponses = {
advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText, advCustomWithoutOptText: responseForAdvancedCustomVariableWithoutOptText,
simpleAndAdv: responseForAdvancedCustomVariable, simpleAndAdv: responseForAdvancedCustomVariable,
allVariableTypes: responsesForAllVariableTypes, allVariableTypes: responsesForAllVariableTypes,
metricLabelValues: responseForMetricLabelValues,
}; };
...@@ -29,6 +29,7 @@ import { ...@@ -29,6 +29,7 @@ import {
toggleStarredValue, toggleStarredValue,
duplicateSystemDashboard, duplicateSystemDashboard,
updateVariablesAndFetchData, updateVariablesAndFetchData,
fetchVariableMetricLabelValues,
} from '~/monitoring/stores/actions'; } from '~/monitoring/stores/actions';
import { import {
gqClient, gqClient,
...@@ -384,14 +385,22 @@ describe('Monitoring store actions', () => { ...@@ -384,14 +385,22 @@ describe('Monitoring store actions', () => {
value: 0, value: 0,
}, },
); );
expect(dispatch).toHaveBeenCalledTimes(1); expect(dispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData'); 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(); expect(createFlash).not.toHaveBeenCalled();
done(); done();
}) })
.catch(done.fail); .catch(done.fail);
}); });
it('dispatches fetchPrometheusMetric for each panel query', done => { it('dispatches fetchPrometheusMetric for each panel query', done => {
state.dashboard.panelGroups = convertObjectPropsToCamelCase( state.dashboard.panelGroups = convertObjectPropsToCamelCase(
metricsDashboardResponse.dashboard.panel_groups, metricsDashboardResponse.dashboard.panel_groups,
...@@ -434,21 +443,27 @@ describe('Monitoring store actions', () => { ...@@ -434,21 +443,27 @@ describe('Monitoring store actions', () => {
const metric = state.dashboard.panelGroups[0].panels[0].metrics[0]; const metric = state.dashboard.panelGroups[0].panels[0].metrics[0];
dispatch.mockResolvedValueOnce(); // fetchDeploymentsData dispatch.mockResolvedValueOnce(); // fetchDeploymentsData
dispatch.mockResolvedValueOnce(); // fetchVariableMetricLabelValues
// Mock having one out of four metrics failing // Mock having one out of four metrics failing
dispatch.mockRejectedValueOnce(new Error('Error fetching this metric')); dispatch.mockRejectedValueOnce(new Error('Error fetching this metric'));
dispatch.mockResolvedValue(); dispatch.mockResolvedValue();
fetchDashboardData({ state, commit, dispatch }) fetchDashboardData({ state, commit, dispatch })
.then(() => { .then(() => {
expect(dispatch).toHaveBeenCalledTimes(metricsDashboardPanelCount + 1); // plus 1 for deployments const defaultQueryParams = {
expect(dispatch).toHaveBeenCalledWith('fetchDeploymentsData');
expect(dispatch).toHaveBeenCalledWith('fetchPrometheusMetric', {
metric,
defaultQueryParams: {
start_time: expect.any(String), start_time: expect.any(String),
end_time: expect.any(String), end_time: expect.any(String),
step: expect.any(Number), 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,
}); });
expect(createFlash).toHaveBeenCalledTimes(1); expect(createFlash).toHaveBeenCalledTimes(1);
...@@ -1116,14 +1131,14 @@ describe('Monitoring store actions', () => { ...@@ -1116,14 +1131,14 @@ describe('Monitoring store actions', () => {
// Variables manipulation // Variables manipulation
describe('updateVariablesAndFetchData', () => { describe('updateVariablesAndFetchData', () => {
it('should commit UPDATE_VARIABLES mutation and fetch data', done => { it('should commit UPDATE_VARIABLE_VALUE mutation and fetch data', done => {
testAction( testAction(
updateVariablesAndFetchData, updateVariablesAndFetchData,
{ pod: 'POD' }, { pod: 'POD' },
state, state,
[ [
{ {
type: types.UPDATE_VARIABLES, type: types.UPDATE_VARIABLE_VALUE,
payload: { pod: 'POD' }, payload: { pod: 'POD' },
}, },
], ],
...@@ -1136,4 +1151,72 @@ describe('Monitoring store actions', () => { ...@@ -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', () => { ...@@ -441,16 +441,57 @@ describe('Monitoring mutations', () => {
}); });
}); });
describe('UPDATE_VARIABLES', () => { describe('UPDATE_VARIABLE_VALUE', () => {
afterEach(() => { afterEach(() => {
mutations[types.SET_VARIABLES](stateCopy, {}); mutations[types.SET_VARIABLES](stateCopy, {});
}); });
it('updates only the value of the variable in variables', () => { it('updates only the value of the variable in variables', () => {
mutations[types.SET_VARIABLES](stateCopy, { environment: { value: 'prod', type: 'text' } }); 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' } }); 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 * as urlUtils from '~/lib/utils/url_utility';
import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data'; import { mockTemplatingData, mockTemplatingDataResponses } from '../mock_data';
describe('parseTemplatingVariables', () => { describe('Monitoring variable mapping', () => {
describe('parseTemplatingVariables', () => {
it.each` it.each`
case | input | expected case | input | expected
${'Returns empty object for no dashboard input'} | ${{}} | ${{}} ${'Returns empty object for no dashboard input'} | ${{}} | ${{}}
...@@ -17,13 +22,14 @@ describe('parseTemplatingVariables', () => { ...@@ -17,13 +22,14 @@ describe('parseTemplatingVariables', () => {
${'Returns parsed object for advanced custom variable without type'} | ${mockTemplatingData.advCustomWithoutType} | ${{}} ${'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 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 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} ${'Returns parsed object for all variable types'} | ${mockTemplatingData.allVariableTypes} | ${mockTemplatingDataResponses.allVariableTypes}
`('$case', ({ input, expected }) => { `('$case', ({ input, expected }) => {
expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected); expect(parseTemplatingVariables(input?.dashboard?.templating)).toEqual(expected);
}); });
}); });
describe('mergeURLVariables', () => { describe('mergeURLVariables', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(urlUtils, 'queryToObject'); jest.spyOn(urlUtils, 'queryToObject');
}); });
...@@ -91,4 +97,89 @@ describe('mergeURLVariables', () => { ...@@ -91,4 +97,89 @@ describe('mergeURLVariables', () => {
expect(mergeURLVariables(ymlParams)).toEqual(merged); expect(mergeURLVariables(ymlParams)).toEqual(merged);
}); });
});
describe('optionsFromSeriesData', () => {
it('fetches the label values from missing data', () => {
expect(optionsFromSeriesData({ label: 'job' })).toEqual([]);
});
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' },
]);
});
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' },
]);
});
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