import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import createGqClient, { fetchPolicies } from '~/lib/graphql'; import { DATETIME_RANGE_TYPES } from '~/lib/utils/constants'; import { timeRangeToParams, getRangeType } from '~/lib/utils/datetime_range'; import { slugify } from '~/lib/utils/text_utility'; import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; import { isSafeURL, mergeUrlParams } from '~/lib/utils/url_utility'; import { NOT_IN_DB_PREFIX, linkTypes, OUT_OF_THE_BOX_DASHBOARDS_PATH_PREFIX } from '../constants'; import { mergeURLVariables, parseTemplatingVariables } from './variable_mapping'; export const gqClient = createGqClient( {}, { fetchPolicy: fetchPolicies.NO_CACHE, }, ); /** * Metrics loaded from project-defined dashboards do not have a metric_id. * This method creates a unique ID combining metric_id and id, if either is present. * This is hopefully a temporary solution until BE processes metrics before passing to FE * * Related: * https://gitlab.com/gitlab-org/gitlab/-/issues/28241 * https://gitlab.com/gitlab-org/gitlab/-/merge_requests/27447 * * @param {Object} metric - metric * @param {Number} metric.metric_id - Database metric id * @param {String} metric.id - User-defined identifier * @returns {Object} - normalized metric with a uniqueID */ // eslint-disable-next-line camelcase export const uniqMetricsId = ({ metric_id, id }) => `${metric_id || NOT_IN_DB_PREFIX}_${id}`; /** * Project path has a leading slash that doesn't work well * with project full path resolver here * https://gitlab.com/gitlab-org/gitlab/blob/5cad4bd721ab91305af4505b2abc92b36a56ad6b/app/graphql/resolvers/full_path_resolver.rb#L10 * * @param {String} str String with leading slash * @returns {String} */ export const removeLeadingSlash = (str) => (str || '').replace(/^\/+/, ''); /** * GraphQL environments API returns only id and name. * For the environments dropdown we need metrics_path. * This method parses the results and add necessary attrs * * @param {Array} response Environments API result * @param {String} projectPath Current project path * @returns {Array} */ export const parseEnvironmentsResponse = (response = [], projectPath) => (response || []).map((env) => { const id = getIdFromGraphQLId(env.id); return { ...env, id, metrics_path: `${projectPath}/-/metrics?environment=${id}`, }; }); /** * Annotation API returns time in UTC. This method * converts time to local time. * * startingAt always exists but endingAt does not. * If endingAt does not exist, a threshold line is * drawn. * * If endingAt exists, a threshold range is drawn. * But this is not supported as of %12.10 * * @param {Array} response annotations response * @returns {Array} parsed responses */ export const parseAnnotationsResponse = (response) => { if (!response) { return []; } return response.map((annotation) => ({ ...annotation, startingAt: new Date(annotation.startingAt), endingAt: annotation.endingAt ? new Date(annotation.endingAt) : null, })); }; /** * Maps metrics to its view model * * This function difers from other in that is maps all * non-define properties as-is to the object. This is not * advisable as it could lead to unexpected side-effects. * * Related issue: * https://gitlab.com/gitlab-org/gitlab/issues/207198 * * @param {Array} metrics - Array of prometheus metrics * @returns {Object} */ const mapToMetricsViewModel = (metrics) => metrics.map(({ label, id, metric_id, query_range, prometheus_endpoint_path, ...metric }) => ({ label, queryRange: query_range, prometheusEndpointPath: prometheus_endpoint_path, metricId: uniqMetricsId({ metric_id, id }), // metric data loading: false, result: null, state: null, ...metric, })); /** * Maps X-axis view model * * @param {Object} axis */ const mapXAxisToViewModel = ({ name = '' }) => ({ name }); /** * Maps Y-axis view model * * Defaults to a 2 digit precision and `engineering` format. It only allows * formats in the SUPPORTED_FORMATS array. * * @param {Object} axis */ const mapYAxisToViewModel = ({ name = '', format = SUPPORTED_FORMATS.engineering, precision = 2, }) => { return { name, format: SUPPORTED_FORMATS[format] || SUPPORTED_FORMATS.engineering, precision, }; }; /** * Maps a link to its view model, expects an url and * (optionally) a title. * * Unsafe URLs are ignored. * * @param {Object} Link * @returns {Object} Link object with a `title`, `url` and `type` * */ const mapLinksToViewModel = ({ url = null, title = '', type } = {}) => { return { title: title || String(url), type, url: url && isSafeURL(url) ? String(url) : '#', }; }; /** * Maps a metrics panel to its view model * * @param {Object} panel - Metrics panel * @returns {Object} */ export const mapPanelToViewModel = ({ id = null, title = '', type, x_axis = {}, // eslint-disable-line camelcase x_label, y_label, y_axis = {}, // eslint-disable-line camelcase field, metrics = [], links = [], min_value, max_value, split, thresholds, format, }) => { // Both `x_axis.name` and `x_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/210521 const xAxis = mapXAxisToViewModel({ name: x_label, ...x_axis }); // eslint-disable-line camelcase // Both `y_axis.name` and `y_label` are supported for now // https://gitlab.com/gitlab-org/gitlab/issues/208385 const yAxis = mapYAxisToViewModel({ name: y_label, ...y_axis }); // eslint-disable-line camelcase return { id, title, type, xLabel: xAxis.name, y_label: yAxis.name, // Changing y_label to yLabel is pending https://gitlab.com/gitlab-org/gitlab/issues/207198 yAxis, xAxis, field, minValue: min_value, maxValue: max_value, split, thresholds, format, links: links.map(mapLinksToViewModel), metrics: mapToMetricsViewModel(metrics), }; }; /** * Maps a metrics panel group to its view model * * @param {Object} panelGroup - Panel Group * @returns {Object} */ const mapToPanelGroupViewModel = ({ group = '', panels = [] }, i) => { return { key: `${slugify(group || 'default')}-${i}`, group, panels: panels.map(mapPanelToViewModel), }; }; /** * Convert dashboard time range to Grafana * dashboards time range. * * @param {Object} timeRange * @returns {Object} */ export const convertToGrafanaTimeRange = (timeRange) => { const timeRangeType = getRangeType(timeRange); if (timeRangeType === DATETIME_RANGE_TYPES.fixed) { return { from: new Date(timeRange.start).getTime(), to: new Date(timeRange.end).getTime(), }; } else if (timeRangeType === DATETIME_RANGE_TYPES.rolling) { const { seconds } = timeRange.duration; return { from: `now-${seconds}s`, to: 'now', }; } // fallback to returning the time range as is return timeRange; }; /** * Convert dashboard time ranges to other supported * link formats. * * @param {Object} timeRange metrics dashboard time range * @param {String} type type of link * @returns {String} */ export const convertTimeRanges = (timeRange, type) => { if (type === linkTypes.GRAFANA) { return convertToGrafanaTimeRange(timeRange); } return timeRangeToParams(timeRange); }; /** * Adds dashboard-related metadata to the user-defined links. * * As of %13.1, metadata only includes timeRange but in the * future more info will be added to the links. * * @param {Object} metadata * @returns {Function} */ export const addDashboardMetaDataToLink = (metadata) => (link) => { let modifiedLink = { ...link }; if (metadata.timeRange) { modifiedLink = { ...modifiedLink, url: mergeUrlParams(convertTimeRanges(metadata.timeRange, link.type), link.url), }; } return modifiedLink; }; /** * Maps a dashboard json object to its view model * * @param {Object} dashboard - Dashboard object * @param {String} dashboard.dashboard - Dashboard name object * @param {Array} dashboard.panel_groups - Panel groups array * @returns {Object} */ export const mapToDashboardViewModel = ({ dashboard = '', templating = {}, links = [], panel_groups = [], // eslint-disable-line camelcase }) => { return { dashboard, 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: * * [ , "" ] * * @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: * * [ , "" ] * * 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": { "": "", ... }, * "value": [ , "" ], * "values": [ [ , "" ] ] * }, * ... * ] * * `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] }; }); /** * Range vectors are returned as result type matrix. * * The corresponding result property has the following format: * * { * "metric": { "": "", ... }, * "value": [ , "" ], * "values": [ [ , "" ], ... ] * }, * * `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 {array} result * @returns {object} Normalized result. */ const normalizeResultMatrix = (result) => result.map(({ metric, values }) => { const mappedValues = values.map(mapScalarValue); return { metric, value: mappedValues[mappedValues.length - 1], values: mappedValues, }; }); /** * Parse response data from a Prometheus Query that comes * in the format: * * { * "resultType": "matrix" | "vector" | "scalar" | "string", * "result": * } * * @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); }; /** * Custom variables defined in the dashboard yml file are * eventually passed over the wire to the backend Prometheus * API proxy. * * This method adds a prefix to the URL param keys so that * the backend can differential these variables from the other * variables. * * This is currently only used by getters/getCustomVariablesParams * * @param {String} name Variable key that needs to be prefixed * @returns {String} */ 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 overview dashboard. * If dashboard is set, it usually is a custom dashboard unless * explicitly it is set to overview 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; };