diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-07-20 15:26:25 +0300 |
commit | a09983ae35713f5a2bbb100981116d31ce99826e (patch) | |
tree | 2ee2af7bd104d57086db360a7e6d8c9d5d43667a /app/assets/javascripts/monitoring/stores | |
parent | 18c5ab32b738c0b6ecb4d0df3994000482f34bd8 (diff) |
Add latest changes from gitlab-org/gitlab@13-2-stable-ee
Diffstat (limited to 'app/assets/javascripts/monitoring/stores')
-rw-r--r-- | app/assets/javascripts/monitoring/stores/actions.js | 124 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/getters.js | 35 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/mutation_types.js | 13 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/mutations.js | 67 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/state.js | 31 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/utils.js | 198 | ||||
-rw-r--r-- | app/assets/javascripts/monitoring/stores/variable_mapping.js | 158 |
7 files changed, 494 insertions, 132 deletions
diff --git a/app/assets/javascripts/monitoring/stores/actions.js b/app/assets/javascripts/monitoring/stores/actions.js index 3a9cccec438..a441882a47d 100644 --- a/app/assets/javascripts/monitoring/stores/actions.js +++ b/app/assets/javascripts/monitoring/stores/actions.js @@ -12,6 +12,7 @@ import { import trackDashboardLoad from '../monitoring_tracking_helper'; import getEnvironments from '../queries/getEnvironments.query.graphql'; import getAnnotations from '../queries/getAnnotations.query.graphql'; +import getDashboardValidationWarnings from '../queries/getDashboardValidationWarnings.query.graphql'; import statusCodes from '../../lib/utils/http_status'; import { backOff, convertObjectPropsToCamelCase } from '../../lib/utils/common_utils'; import { s__, sprintf } from '../../locale'; @@ -20,6 +21,7 @@ import { PROMETHEUS_TIMEOUT, ENVIRONMENT_AVAILABLE_STATE, DEFAULT_DASHBOARD_PATH, + VARIABLE_TYPES, } from '../constants'; function prometheusMetricQueryParams(timeRange) { @@ -50,15 +52,14 @@ function backOffRequest(makeRequestCallback) { }, PROMETHEUS_TIMEOUT); } -function getPrometheusMetricResult(prometheusEndpoint, params) { +function getPrometheusQueryData(prometheusEndpoint, params) { return backOffRequest(() => axios.get(prometheusEndpoint, { params })) .then(res => res.data) .then(response => { if (response.status === 'error') { throw new Error(response.error); } - - return response.data.result; + return response.data; }); } @@ -76,10 +77,6 @@ export const setTimeRange = ({ commit }, timeRange) => { commit(types.SET_TIME_RANGE, timeRange); }; -export const setVariables = ({ commit }, variables) => { - commit(types.SET_VARIABLES, variables); -}; - export const filterEnvironments = ({ commit, dispatch }, searchTerm) => { commit(types.SET_ENVIRONMENTS_FILTER, searchTerm); dispatch('fetchEnvironmentsData'); @@ -100,6 +97,10 @@ export const clearExpandedPanel = ({ commit }) => { }); }; +export const setCurrentDashboard = ({ commit }, { currentDashboard }) => { + commit(types.SET_CURRENT_DASHBOARD, currentDashboard); +}; + // All Data /** @@ -117,17 +118,27 @@ export const fetchData = ({ dispatch }) => { // Metrics dashboard -export const fetchDashboard = ({ state, commit, dispatch }) => { +export const fetchDashboard = ({ state, commit, dispatch, getters }) => { dispatch('requestMetricsDashboard'); const params = {}; - if (state.currentDashboard) { - params.dashboard = state.currentDashboard; + if (getters.fullDashboardPath) { + params.dashboard = getters.fullDashboardPath; } return backOffRequest(() => axios.get(state.dashboardEndpoint, { params })) .then(resp => resp.data) - .then(response => dispatch('receiveMetricsDashboardSuccess', { response })) + .then(response => { + dispatch('receiveMetricsDashboardSuccess', { response }); + /** + * After the dashboard is fetched, there can be non-blocking invalid syntax + * in the dashboard file. This call will fetch such syntax warnings + * and surface a warning on the UI. If the invalid syntax is blocking, + * the `fetchDashboard` returns a 404 with error messages that are displayed + * on the UI. + */ + dispatch('fetchDashboardValidationWarnings'); + }) .catch(error => { Sentry.captureException(error); @@ -181,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 => { @@ -194,7 +209,7 @@ export const fetchDashboardData = ({ state, dispatch, getters }) => { return Promise.all(promises) .then(() => { - const dashboardType = state.currentDashboard === '' ? 'default' : 'custom'; + const dashboardType = getters.fullDashboardPath === '' ? 'default' : 'custom'; trackDashboardLoad({ label: `${dashboardType}_metrics_dashboard`, value: getters.metricsWithData().length, @@ -220,7 +235,7 @@ export const fetchPrometheusMetric = ( queryParams.step = metric.step; } - if (Object.keys(state.variables).length > 0) { + if (state.variables.length > 0) { queryParams = { ...queryParams, ...getters.getCustomVariablesParams, @@ -229,9 +244,9 @@ export const fetchPrometheusMetric = ( commit(types.REQUEST_METRIC_RESULT, { metricId: metric.metricId }); - return getPrometheusMetricResult(metric.prometheusEndpointPath, queryParams) - .then(result => { - commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, result }); + return getPrometheusQueryData(metric.prometheusEndpointPath, queryParams) + .then(data => { + commit(types.RECEIVE_METRIC_RESULT_SUCCESS, { metricId: metric.metricId, data }); }) .catch(error => { Sentry.captureException(error); @@ -312,9 +327,9 @@ export const receiveEnvironmentsDataFailure = ({ commit }) => { commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE); }; -export const fetchAnnotations = ({ state, dispatch }) => { +export const fetchAnnotations = ({ state, dispatch, getters }) => { const { start } = convertToFixedRange(state.timeRange); - const dashboardPath = state.currentDashboard || DEFAULT_DASHBOARD_PATH; + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; return gqClient .mutate({ mutation: getAnnotations, @@ -345,6 +360,46 @@ export const receiveAnnotationsSuccess = ({ commit }, data) => commit(types.RECEIVE_ANNOTATIONS_SUCCESS, data); export const receiveAnnotationsFailure = ({ commit }) => commit(types.RECEIVE_ANNOTATIONS_FAILURE); +export const fetchDashboardValidationWarnings = ({ state, dispatch, getters }) => { + /** + * Normally, the default dashboard won't throw any validation warnings. + * + * However, if a bug sneaks into the default dashboard making it invalid, + * this might come handy for our clients + */ + const dashboardPath = getters.fullDashboardPath || DEFAULT_DASHBOARD_PATH; + return gqClient + .mutate({ + mutation: getDashboardValidationWarnings, + variables: { + projectPath: removeLeadingSlash(state.projectPath), + environmentName: state.currentEnvironmentName, + dashboardPath, + }, + }) + .then(resp => resp.data?.project?.environments?.nodes?.[0]?.metricsDashboard) + .then(({ schemaValidationWarnings } = {}) => { + const hasWarnings = schemaValidationWarnings && schemaValidationWarnings.length !== 0; + /** + * The payload of the dispatch is a boolean, because at the moment a standard + * warning message is shown instead of the warnings the BE returns + */ + dispatch('receiveDashboardValidationWarningsSuccess', hasWarnings || false); + }) + .catch(err => { + Sentry.captureException(err); + dispatch('receiveDashboardValidationWarningsFailure'); + createFlash( + s__('Metrics|There was an error getting dashboard validation warnings information.'), + ); + }); +}; + +export const receiveDashboardValidationWarningsSuccess = ({ commit }, hasWarnings) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS, hasWarnings); +export const receiveDashboardValidationWarningsFailure = ({ commit }) => + commit(types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE); + // Dashboard manipulation export const toggleStarredValue = ({ commit, state, getters }) => { @@ -416,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 = []; + + state.variables.forEach(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: variable.name, + }), + ); + }); + optionsRequests.push(optionsRequest); + } + }); + + return Promise.all(optionsRequests); +}; + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/getters.js b/app/assets/javascripts/monitoring/stores/getters.js index b7681012472..3aa711a0509 100644 --- a/app/assets/javascripts/monitoring/stores/getters.js +++ b/app/assets/javascripts/monitoring/stores/getters.js @@ -1,5 +1,9 @@ import { NOT_IN_DB_PREFIX } from '../constants'; -import { addPrefixToCustomVariableParams, addDashboardMetaDataToLink } from './utils'; +import { + addPrefixToCustomVariableParams, + addDashboardMetaDataToLink, + normalizeCustomDashboardPath, +} from './utils'; const metricsIdsInPanel = panel => panel.metrics.filter(metric => metric.metricId && metric.result).map(metric => metric.metricId); @@ -10,10 +14,10 @@ const metricsIdsInPanel = panel => * * @param {Object} state */ -export const selectedDashboard = state => { +export const selectedDashboard = (state, getters) => { const { allDashboards } = state; return ( - allDashboards.find(d => d.path === state.currentDashboard) || + allDashboards.find(d => d.path === getters.fullDashboardPath) || allDashboards.find(d => d.default) || null ); @@ -129,8 +133,8 @@ export const linksWithMetadata = state => { }; /** - * Maps an variables object to an array along with stripping - * the variable prefix. + * Maps a variables array to an object for replacement in + * prometheus queries. * * This method outputs an object in the below format * @@ -143,16 +147,29 @@ export const linksWithMetadata = state => { * user-defined variables coming through the URL and differentiate * from other variables used for Prometheus API endpoint. * - * @param {Object} variables - Custom variables provided by the user - * @returns {Array} The custom variables array to be send to the API + * @param {Object} state - State containing variables provided by the user + * @returns {Array} The custom variables object to be send to the API * in the format of {variables[key1]=value1, variables[key2]=value2} */ export const getCustomVariablesParams = state => - Object.keys(state.variables).reduce((acc, variable) => { - acc[addPrefixToCustomVariableParams(variable)] = state.variables[variable]?.value; + state.variables.reduce((acc, variable) => { + const { name, value } = variable; + if (value !== null) { + acc[addPrefixToCustomVariableParams(name)] = value; + } return acc; }, {}); +/** + * For a given custom dashboard file name, this method + * returns the full file path. + * + * @param {Object} state + * @returns {String} full dashboard path + */ +export const fullDashboardPath = state => + normalizeCustomDashboardPath(state.currentDashboard, state.customDashboardBasePath); + // prevent babel-plugin-rewire from generating an invalid default during karma tests export default () => {}; diff --git a/app/assets/javascripts/monitoring/stores/mutation_types.js b/app/assets/javascripts/monitoring/stores/mutation_types.js index 4593461776b..d408628fc4d 100644 --- a/app/assets/javascripts/monitoring/stores/mutation_types.js +++ b/app/assets/javascripts/monitoring/stores/mutation_types.js @@ -2,17 +2,25 @@ 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'; export const RECEIVE_DASHBOARD_STARRING_FAILURE = 'RECEIVE_DASHBOARD_STARRING_FAILURE'; +export const SET_CURRENT_DASHBOARD = 'SET_CURRENT_DASHBOARD'; + // Annotations export const RECEIVE_ANNOTATIONS_SUCCESS = 'RECEIVE_ANNOTATIONS_SUCCESS'; export const RECEIVE_ANNOTATIONS_FAILURE = 'RECEIVE_ANNOTATIONS_FAILURE'; +// Dashboard validation warnings +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS'; +export const RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE = + 'RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE'; + // Git project deployments export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA'; export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS'; @@ -34,7 +42,6 @@ export const SET_ALL_DASHBOARDS = 'SET_ALL_DASHBOARDS'; export const SET_ENDPOINTS = 'SET_ENDPOINTS'; export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE'; -export const SET_NO_DATA_EMPTY_STATE = 'SET_NO_DATA_EMPTY_STATE'; export const SET_SHOW_ERROR_BANNER = 'SET_SHOW_ERROR_BANNER'; export const SET_PANEL_GROUP_METRICS = 'SET_PANEL_GROUP_METRICS'; export const SET_ENVIRONMENTS_FILTER = 'SET_ENVIRONMENTS_FILTER'; diff --git a/app/assets/javascripts/monitoring/stores/mutations.js b/app/assets/javascripts/monitoring/stores/mutations.js index 2d63fdd6e34..744441c8935 100644 --- a/app/assets/javascripts/monitoring/stores/mutations.js +++ b/app/assets/javascripts/monitoring/stores/mutations.js @@ -1,10 +1,11 @@ import Vue from 'vue'; import { pick } from 'lodash'; import * as types from './mutation_types'; -import { mapToDashboardViewModel, normalizeQueryResult } from './utils'; -import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; -import { endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { mapToDashboardViewModel, normalizeQueryResponseData } from './utils'; import httpStatusCodes from '~/lib/utils/http_status'; +import { BACKOFF_TIMEOUT } from '../../lib/utils/common_utils'; +import { dashboardEmptyStates, endpointKeys, initialStateKeys, metricStates } from '../constants'; +import { optionsFromSeriesData } from './variable_mapping'; /** * Locate and return a metric in the dashboard by its id @@ -57,8 +58,7 @@ export default { * Dashboard panels structure and global state */ [types.REQUEST_METRICS_DASHBOARD](state) { - state.emptyState = 'loading'; - state.showEmptyState = true; + state.emptyState = dashboardEmptyStates.LOADING; }, [types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, dashboardYML) { const { dashboard, panelGroups, variables, links } = mapToDashboardViewModel(dashboardYML); @@ -70,12 +70,15 @@ export default { state.links = links; if (!state.dashboard.panelGroups.length) { - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.NO_DATA; + } else { + state.emptyState = null; } }, [types.RECEIVE_METRICS_DASHBOARD_FAILURE](state, error) { - state.emptyState = error ? 'unableToConnect' : 'noData'; - state.showEmptyState = true; + state.emptyState = error + ? dashboardEmptyStates.UNABLE_TO_CONNECT + : dashboardEmptyStates.NO_DATA; }, [types.REQUEST_DASHBOARD_STARRING](state) { @@ -94,6 +97,10 @@ export default { state.isUpdatingStarredValue = false; }, + [types.SET_CURRENT_DASHBOARD](state, currentDashboard) { + state.currentDashboard = currentDashboard; + }, + /** * Deployments and environments */ @@ -126,6 +133,16 @@ export default { }, /** + * Dashboard Validation Warnings + */ + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_SUCCESS](state, hasDashboardValidationWarnings) { + state.hasDashboardValidationWarnings = hasDashboardValidationWarnings; + }, + [types.RECEIVE_DASHBOARD_VALIDATION_WARNINGS_FAILURE](state) { + state.hasDashboardValidationWarnings = false; + }, + + /** * Individual panel/metric results */ [types.REQUEST_METRIC_RESULT](state, { metricId }) { @@ -135,19 +152,18 @@ export default { metric.state = metricStates.LOADING; } }, - [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, result }) { + [types.RECEIVE_METRIC_RESULT_SUCCESS](state, { metricId, data }) { const metric = findMetricInDashboard(metricId, state.dashboard); metric.loading = false; - state.showEmptyState = false; - if (!result || result.length === 0) { + if (!data.result || data.result.length === 0) { metric.state = metricStates.NO_DATA; metric.result = null; } else { - const normalizedResults = result.map(normalizeQueryResult); + const result = normalizeQueryResponseData(data); metric.state = metricStates.OK; - metric.result = Object.freeze(normalizedResults); + metric.result = Object.freeze(result); } }, [types.RECEIVE_METRIC_RESULT_FAILURE](state, { metricId, error }) { @@ -169,11 +185,7 @@ export default { state.timeRange = timeRange; }, [types.SET_GETTING_STARTED_EMPTY_STATE](state) { - state.emptyState = 'gettingStarted'; - }, - [types.SET_NO_DATA_EMPTY_STATE](state) { - state.showEmptyState = true; - state.emptyState = 'noData'; + state.emptyState = dashboardEmptyStates.GETTING_STARTED; }, [types.SET_ALL_DASHBOARDS](state, dashboards) { state.allDashboards = dashboards || []; @@ -192,13 +204,18 @@ export default { state.expandedPanel.group = group; state.expandedPanel.panel = panel; }, - [types.SET_VARIABLES](state, variables) { - state.variables = variables; + [types.UPDATE_VARIABLE_VALUE](state, { name, value }) { + const variable = state.variables.find(v => v.name === name); + if (variable) { + Object.assign(variable, { + value, + }); + } }, - [types.UPDATE_VARIABLES](state, updatedVariable) { - Object.assign(state.variables[updatedVariable.key], { - ...state.variables[updatedVariable.key], - value: updatedVariable.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 }); }, }; diff --git a/app/assets/javascripts/monitoring/stores/state.js b/app/assets/javascripts/monitoring/stores/state.js index 8000f27c0d5..89738756ffe 100644 --- a/app/assets/javascripts/monitoring/stores/state.js +++ b/app/assets/javascripts/monitoring/stores/state.js @@ -1,5 +1,6 @@ import invalidUrl from '~/lib/utils/invalid_url'; import { timezones } from '../format_date'; +import { dashboardEmptyStates } from '../constants'; export default () => ({ // API endpoints @@ -9,11 +10,24 @@ export default () => ({ // Dashboard request parameters timeRange: null, + /** + * Currently selected dashboard. For custom dashboards, + * this could be the filename or the file path. + * + * If this is the filename and full path is required, + * getters.fullDashboardPath should be used. + */ currentDashboard: null, // Dashboard data - emptyState: 'gettingStarted', - showEmptyState: true, + hasDashboardValidationWarnings: false, + + /** + * {?String} If set, dashboard should display a global + * empty state, there is no way to interact (yet) + * with the dashboard. + */ + emptyState: dashboardEmptyStates.GETTING_STARTED, showErrorBanner: true, isUpdatingStarredValue: false, dashboard: { @@ -39,7 +53,7 @@ export default () => ({ * User-defined custom variables are passed * via the dashboard yml file. */ - variables: {}, + variables: [], /** * User-defined custom links are passed * via the dashboard yml file. @@ -56,5 +70,16 @@ export default () => ({ // GitLab paths to other pages projectPath: null, + operationsSettingsPath: '', logsPath: invalidUrl, + + // static paths + customDashboardBasePath: '', + + // current user data + /** + * Flag that denotes if the currently logged user can access + * the project Settings -> Operations + */ + canAccessOperationsSettings: false, }); diff --git a/app/assets/javascripts/monitoring/stores/utils.js b/app/assets/javascripts/monitoring/stores/utils.js index 5795e756282..51562593ee8 100644 --- a/app/assets/javascripts/monitoring/stores/utils.js +++ b/app/assets/javascripts/monitoring/stores/utils.js @@ -2,11 +2,11 @@ import { slugify } from '~/lib/utils/text_utility'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { NOT_IN_DB_PREFIX, linkTypes } from '../constants'; import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; +import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants'; export const gqClient = createGqClient( {}, @@ -165,7 +165,7 @@ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => { * @param {Object} panel - Metrics panel * @returns {Object} */ -const mapPanelToViewModel = ({ +export const mapPanelToViewModel = ({ id = null, title = '', type, @@ -173,6 +173,7 @@ const mapPanelToViewModel = ({ x_label, y_label, y_axis = {}, + field, metrics = [], links = [], max_value, @@ -193,6 +194,7 @@ const mapPanelToViewModel = ({ y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198 yAxis, xAxis, + field, maxValue: max_value, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), @@ -289,49 +291,157 @@ export const mapToDashboardViewModel = ({ }) => { return { dashboard, - variables: mergeURLVariables(parseTemplatingVariables(templating)), + variables: mergeURLVariables(parseTemplatingVariables(templating.variables)), links: links.map(mapLinksToViewModel), panelGroups: panel_groups.map(mapToPanelGroupViewModel), }; }; +// Prometheus Results Parsing + +const dateTimeFromUnixTime = unixTime => new Date(unixTime * 1000).toISOString(); + +const mapScalarValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), Number(value)]; + +// Note: `string` value type is unused as of prometheus 2.19. +const mapStringValue = ([unixTime, value]) => [dateTimeFromUnixTime(unixTime), value]; + +/** + * Processes a scalar result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<scalar_value>" ] + * + * @param {array} result + * @returns {array} + */ +const normalizeScalarResult = result => [ + { + metric: {}, + value: mapScalarValue(result), + values: [mapScalarValue(result)], + }, +]; + +/** + * Processes a string result. + * + * The corresponding result property has the following format: + * + * [ <unix_time>, "<string_value>" ] + * + * Note: This value type is unused as of prometheus 2.19. + * + * @param {array} result + * @returns {array} + */ +const normalizeStringResult = result => [ + { + metric: {}, + value: mapStringValue(result), + values: [mapStringValue(result)], + }, +]; + +/** + * Proccesses an instant vector. + * + * Instant vectors are returned as result type `vector`. + * + * The corresponding result property has the following format: + * + * [ + * { + * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], + * "values": [ [ <unix_time>, "<sample_value>" ] ] + * }, + * ... + * ] + * + * `metric` - Key-value pairs object representing metric measured + * `value` - The vector result + * `values` - An array with a single value representing the result + * + * This method also adds the matrix version of the vector + * by introducing a `values` array with a single element. This + * allows charts to default to `values` if needed. + * + * @param {array} result + * @returns {array} + */ +const normalizeVectorResult = result => + result.map(({ metric, value }) => { + const scalar = mapScalarValue(value); + // Add a single element to `values`, to support matrix + // style charts. + return { metric, value: scalar, values: [scalar] }; + }); + /** - * Processes a single Range vector, part of the result - * of type `matrix` in the form: + * Range vectors are returned as result type matrix. + * + * The corresponding result property has the following format: * * { * "metric": { "<label_name>": "<label_value>", ... }, + * "value": [ <unix_time>, "<sample_value>" ], * "values": [ [ <unix_time>, "<sample_value>" ], ... ] * }, * + * `metric` - Key-value pairs object representing metric measured + * `value` - The last (more recent) result + * `values` - A range of results for the metric + * * See https://prometheus.io/docs/prometheus/latest/querying/api/#range-vectors * - * @param {*} timeSeries + * @param {array} result + * @returns {object} Normalized result. */ -export const normalizeQueryResult = timeSeries => { - let normalizedResult = {}; - - if (timeSeries.values) { - normalizedResult = { - ...timeSeries, - values: timeSeries.values.map(([timestamp, value]) => [ - new Date(timestamp * 1000).toISOString(), - Number(value), - ]), - }; - // Check result for empty data - normalizedResult.values = normalizedResult.values.filter(series => { - const hasValue = d => !Number.isNaN(d[1]) && (d[1] !== null || d[1] !== undefined); - return series.find(hasValue); - }); - } else if (timeSeries.value) { - normalizedResult = { - ...timeSeries, - value: [new Date(timeSeries.value[0] * 1000).toISOString(), Number(timeSeries.value[1])], +const normalizeResultMatrix = result => + result.map(({ metric, values }) => { + const mappedValues = values.map(mapScalarValue); + return { + metric, + value: mappedValues[mappedValues.length - 1], + values: mappedValues, }; - } + }); - return normalizedResult; +/** + * Parse response data from a Prometheus Query that comes + * in the format: + * + * { + * "resultType": "matrix" | "vector" | "scalar" | "string", + * "result": <value> + * } + * + * @see https://prometheus.io/docs/prometheus/latest/querying/api/#expression-query-result-formats + * + * @param {object} data - Data containing results and result type. + * @returns {object} - A result array of metric results: + * [ + * { + * metric: { ... }, + * value: ['2015-07-01T20:10:51.781Z', '1'], + * values: [['2015-07-01T20:10:51.781Z', '1'] , ... ], + * }, + * ... + * ] + * + */ +export const normalizeQueryResponseData = data => { + const { resultType, result } = data; + if (resultType === 'vector') { + return normalizeVectorResult(result); + } else if (resultType === 'scalar') { + return normalizeScalarResult(result); + } else if (resultType === 'string') { + return normalizeStringResult(result); + } + return normalizeResultMatrix(result); }; /** @@ -345,7 +455,35 @@ export const normalizeQueryResult = timeSeries => { * * This is currently only used by getters/getCustomVariablesParams * - * @param {String} key Variable key that needs to be prefixed + * @param {String} name Variable key that needs to be prefixed * @returns {String} */ -export const addPrefixToCustomVariableParams = key => `variables[${key}]`; +export const addPrefixToCustomVariableParams = name => `variables[${name}]`; + +/** + * Normalize custom dashboard paths. This method helps support + * metrics dashboard to work with custom dashboard file names instead + * of the entire path. + * + * If dashboard is empty, it is the default dashboard. + * If dashboard is set, it usually is a custom dashboard unless + * explicitly it is set to default dashboard path. + * + * @param {String} dashboard dashboard path + * @param {String} dashboardPrefix custom dashboard directory prefix + * @returns {String} normalized dashboard path + */ +export const normalizeCustomDashboardPath = (dashboard, dashboardPrefix = '') => { + const currDashboard = dashboard || ''; + let dashboardPath = `${dashboardPrefix}/${currDashboard}`; + + if (!currDashboard) { + dashboardPath = ''; + } else if ( + currDashboard.startsWith(dashboardPrefix) || + currDashboard.startsWith(OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX) + ) { + dashboardPath = currDashboard; + } + return dashboardPath; +}; diff --git a/app/assets/javascripts/monitoring/stores/variable_mapping.js b/app/assets/javascripts/monitoring/stores/variable_mapping.js index c0a8150063b..9245ffdb3b9 100644 --- a/app/assets/javascripts/monitoring/stores/variable_mapping.js +++ b/app/assets/javascripts/monitoring/stores/variable_mapping.js @@ -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 = null }) => ({ 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, + options: { + values, + }, + value: defaultValue?.value || null, }; }; @@ -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,15 +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, label: null, - options: options.map(normalizeCustomVariableOptions), + value: values[0].value || null, + options: { + values: values.map(normalizeVariableValues), + }, }; }; +const metricLabelValuesVariableParser = ({ label, options = {} }) => ({ + type: VARIABLE_TYPES.metric_label_values, + label, + value: null, + options: { + prometheusEndpointPath: options.prometheus_endpoint_path || '', + label: options.label || null, + values: [], // values are initially empty + }, +}); + /** * Utility method to determine if a custom variable is * simple or not. If its not simple, it is advanced. @@ -123,14 +138,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; }; @@ -141,29 +158,26 @@ const getVariableParser = variable => { * for the user to edit. The values from input elements are relayed to * backend and eventually Prometheus API. * - * This method currently is not used anywhere. Once the issue - * https://gitlab.com/gitlab-org/gitlab/-/issues/214536 is completed, - * this method will have been used by the monitoring dashboard. - * - * @param {Object} templating templating variables from the dashboard yml file - * @returns {Object} a map of processed templating variables + * @param {Object} templating variables from the dashboard yml file + * @returns {array} An array of variables to display as inputs */ -export const parseTemplatingVariables = ({ variables = {} } = {}) => - Object.entries(variables).reduce((acc, [key, variable]) => { +export const parseTemplatingVariables = (ymlVariables = {}) => + Object.entries(ymlVariables).reduce((acc, [name, ymlVariable]) => { // get the parser - const parser = getVariableParser(variable); + const parser = getVariableParser(ymlVariable); // parse the variable - const parsedVar = parser(variable); + const variable = parser(ymlVariable); // for simple custom variable label is null and it should be // replace with key instead - if (parsedVar) { - acc[key] = { - ...parsedVar, - label: parsedVar.label || key, - }; + if (variable) { + acc.push({ + ...variable, + name, + label: variable.label || name, + }); } return acc; - }, {}); + }, []); /** * Custom variables are defined in the dashboard yml file @@ -181,23 +195,81 @@ export const parseTemplatingVariables = ({ variables = {} } = {}) => * This method can be improved further. See the below issue * https://gitlab.com/gitlab-org/gitlab/-/issues/217713 * - * @param {Object} varsFromYML template variables from yml file + * @param {array} parsedYmlVariables - template variables from yml file * @returns {Object} */ -export const mergeURLVariables = (varsFromYML = {}) => { +export const mergeURLVariables = (parsedYmlVariables = []) => { const varsFromURL = templatingVariablesFromUrl(); - const variables = {}; - Object.keys(varsFromYML).forEach(key => { - if (Object.prototype.hasOwnProperty.call(varsFromURL, key)) { - variables[key] = { - ...varsFromYML[key], - value: varsFromURL[key], - }; - } else { - variables[key] = varsFromYML[key]; + parsedYmlVariables.forEach(variable => { + const { name } = variable; + if (Object.prototype.hasOwnProperty.call(varsFromURL, name)) { + Object.assign(variable, { value: varsFromURL[name] }); } }); - return variables; + return parsedYmlVariables; +}; + +/** + * 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 {}; |