diff options
Diffstat (limited to 'app/assets/javascripts/analytics')
13 files changed, 761 insertions, 0 deletions
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue new file mode 100644 index 00000000000..7aa5c98aa0b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -0,0 +1,30 @@ +<script> +import InstanceCounts from './instance_counts.vue'; +import PipelinesChart from './pipelines_chart.vue'; +import UsersChart from './users_chart.vue'; +import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; + +export default { + name: 'InstanceStatisticsApp', + components: { + InstanceCounts, + PipelinesChart, + UsersChart, + }, + TOTAL_DAYS_TO_SHOW, + START_DATE, + TODAY, +}; +</script> + +<template> + <div> + <instance-counts /> + <users-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <pipelines-chart /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue new file mode 100644 index 00000000000..4fbfb4daf22 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_counts.vue @@ -0,0 +1,64 @@ +<script> +import * as Sentry from '~/sentry/wrapper'; +import { s__ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import instanceStatisticsCountQuery from '../graphql/queries/instance_statistics_count.query.graphql'; + +const defaultPrecision = 0; + +export default { + name: 'InstanceCounts', + components: { + MetricCard, + }, + data() { + return { + counts: [], + }; + }, + apollo: { + counts: { + query: instanceStatisticsCountQuery, + update(data) { + return Object.entries(data).map(([key, obj]) => { + const label = this.$options.i18n.labels[key]; + const formatter = getFormatter(SUPPORTED_FORMATS.number); + const value = obj.nodes?.length ? formatter(obj.nodes[0].count, defaultPrecision) : null; + + return { + key, + value, + label, + }; + }); + }, + error(error) { + createFlash(this.$options.i18n.loadCountsError); + Sentry.captureException(error); + }, + }, + }, + i18n: { + labels: { + users: s__('InstanceStatistics|Users'), + projects: s__('InstanceStatistics|Projects'), + groups: s__('InstanceStatistics|Groups'), + issues: s__('InstanceStatistics|Issues'), + mergeRequests: s__('InstanceStatistics|Merge Requests'), + pipelines: s__('InstanceStatistics|Pipelines'), + }, + loadCountsError: s__('Could not load instance counts. Please refresh the page to try again.'), + }, +}; +</script> + +<template> + <metric-card + :title="__('Instance Statistics')" + :metrics="counts" + :is-loading="$apollo.queries.counts.loading" + class="gl-mt-4" + /> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue new file mode 100644 index 00000000000..b16d960402b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue @@ -0,0 +1,215 @@ +<script> +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import { mapKeys, mapValues, pick, some, sum } from 'lodash'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { s__ } from '~/locale'; +import { + differenceInMonths, + formatDateAsMonth, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { getAverageByMonth, sortByDate, extractValues } from '../utils'; +import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql'; +import { TODAY, START_DATE } from '../constants'; + +const DATA_KEYS = [ + 'pipelinesTotal', + 'pipelinesSucceeded', + 'pipelinesFailed', + 'pipelinesCanceled', + 'pipelinesSkipped', +]; +const PREFIX = 'pipelines'; + +export default { + name: 'PipelinesChart', + components: { + GlLineChart, + GlAlert, + ChartSkeletonLoader, + }, + startDate: START_DATE, + endDate: TODAY, + i18n: { + loadPipelineChartError: s__( + 'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.', + ), + noDataMessage: s__('InstanceAnalytics|There is no data available.'), + total: s__('InstanceAnalytics|Total'), + succeeded: s__('InstanceAnalytics|Succeeded'), + failed: s__('InstanceAnalytics|Failed'), + canceled: s__('InstanceAnalytics|Canceled'), + skipped: s__('InstanceAnalytics|Skipped'), + chartTitle: s__('InstanceAnalytics|Pipelines'), + yAxisTitle: s__('InstanceAnalytics|Items'), + xAxisTitle: s__('InstanceAnalytics|Month'), + }, + data() { + return { + loading: true, + loadingError: null, + }; + }, + apollo: { + pipelineStats: { + query: pipelineStatsQuery, + variables() { + return { + firstTotal: this.totalDaysToShow, + firstSucceeded: this.totalDaysToShow, + firstFailed: this.totalDaysToShow, + firstCanceled: this.totalDaysToShow, + firstSkipped: this.totalDaysToShow, + }; + }, + update(data) { + const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes'); + const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo'); + + return { + ...mapValues(allData, sortByDate), + ...allPageInfo, + }; + }, + result() { + if (this.hasNextPage) { + this.fetchNextPage(); + } + }, + error() { + this.handleError(); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.pipelineStats.loading; + }, + totalDaysToShow() { + return getDayDifference(this.$options.startDate, this.$options.endDate); + }, + firstVariables() { + const allData = pick(this.pipelineStats, [ + 'nodesTotal', + 'nodesSucceeded', + 'nodesFailed', + 'nodesCanceled', + 'nodesSkipped', + ]); + const allDayDiffs = mapValues(allData, data => { + const firstdataPoint = data[0]; + if (!firstdataPoint) { + return 0; + } + + return Math.max( + 0, + getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)), + ); + }); + + return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first')); + }, + cursorVariables() { + const pageInfoKeys = [ + 'pageInfoTotal', + 'pageInfoSucceeded', + 'pageInfoFailed', + 'pageInfoCanceled', + 'pageInfoSkipped', + ]; + + return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor'); + }, + hasNextPage() { + return ( + sum(Object.values(this.firstVariables)) > 0 && + some(this.pipelineStats, ({ hasNextPage }) => hasNextPage) + ); + }, + hasEmptyDataSet() { + return this.chartData.every(({ data }) => data.length === 0); + }, + chartData() { + const allData = pick(this.pipelineStats, [ + 'nodesTotal', + 'nodesSucceeded', + 'nodesFailed', + 'nodesCanceled', + 'nodesSkipped', + ]); + const options = { shouldRound: true }; + return Object.keys(allData).map(key => { + const i18nName = key.slice('nodes'.length).toLowerCase(); + return { + name: this.$options.i18n[i18nName], + data: getAverageByMonth(allData[key], options), + }; + }); + }, + range() { + return { + min: this.$options.startDate, + max: this.$options.endDate, + }; + }, + chartOptions() { + const { endDate, startDate, i18n } = this.$options; + return { + xAxis: { + ...this.range, + name: i18n.xAxisTitle, + type: 'time', + splitNumber: differenceInMonths(startDate, endDate) + 1, + axisLabel: { + interval: 0, + showMinLabel: false, + showMaxLabel: false, + align: 'right', + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: i18n.yAxisTitle, + }, + }; + }, + }, + methods: { + handleError() { + this.loadingError = true; + }, + fetchNextPage() { + this.$apollo.queries.pipelineStats + .fetchMore({ + variables: { + ...this.firstVariables, + ...this.cursorVariables, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + return Object.keys(fetchMoreResult).reduce((memo, key) => { + const { nodes, ...rest } = fetchMoreResult[key]; + const previousNodes = previousResult[key].nodes; + return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } }; + }, {}); + }, + }) + .catch(this.handleError); + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.chartTitle }}</h3> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ this.$options.i18n.loadPipelineChartError }} + </gl-alert> + <chart-skeleton-loader v-else-if="isLoading" /> + <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue new file mode 100644 index 00000000000..a4a1d40b70b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/users_chart.vue @@ -0,0 +1,143 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { __ } from '~/locale'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import usersQuery from '../graphql/queries/users.query.graphql'; +import { getAverageByMonth } from '../utils'; + +const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime()); + +export default { + name: 'UsersChart', + components: { GlAlert, GlAreaChart, ChartSkeletonLoader }, + props: { + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + totalDataPoints: { + type: Number, + required: true, + }, + }, + data() { + return { + loadingError: null, + users: [], + pageInfo: null, + }; + }, + apollo: { + users: { + query: usersQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.users?.nodes || []; + }, + result({ data }) { + const { + users: { pageInfo }, + } = data; + this.pageInfo = pageInfo; + this.fetchNextPage(); + }, + error(error) { + this.handleError(error); + }, + }, + }, + i18n: { + yAxisTitle: __('Total users'), + xAxisTitle: __('Month'), + loadUserChartError: __('Could not load the user chart. Please refresh the page to try again.'), + noDataMessage: __('There is no data available.'), + }, + computed: { + isLoading() { + return this.$apollo.queries.users.loading || this.pageInfo?.hasNextPage; + }, + chartUserData() { + const averaged = getAverageByMonth( + this.users.length > this.totalDataPoints + ? this.users.slice(0, this.totalDataPoints) + : this.users, + { shouldRound: true }, + ); + return sortByDate(averaged); + }, + options() { + return { + xAxis: { + name: this.$options.i18n.xAxisTitle, + type: 'category', + axisLabel: { + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + }, + }; + }, + }, + methods: { + handleError(error) { + this.loadingError = true; + this.users = []; + Sentry.captureException(error); + }, + fetchNextPage() { + if (this.pageInfo?.hasNextPage) { + this.$apollo.queries.users + .fetchMore({ + variables: { first: this.totalDataPoints, after: this.pageInfo.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => { + return produce(fetchMoreResult, newUsers => { + // eslint-disable-next-line no-param-reassign + newUsers.users.nodes = [...previousResult.users.nodes, ...newUsers.users.nodes]; + }); + }, + }) + .catch(this.handleError); + } + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.yAxisTitle }}</h3> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ this.$options.i18n.loadUserChartError }} + </gl-alert> + <chart-skeleton-loader v-else-if="isLoading" /> + <gl-alert v-else-if="!chartUserData.length" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <gl-area-chart + v-else + :option="options" + :include-legend-avg-max="true" + :data="[ + { + name: $options.i18n.yAxisTitle, + data: chartUserData, + }, + ]" + /> + </div> +</template> diff --git a/app/assets/javascripts/analytics/instance_statistics/constants.js b/app/assets/javascripts/analytics/instance_statistics/constants.js new file mode 100644 index 00000000000..846c0ef408b --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/constants.js @@ -0,0 +1,5 @@ +import { getDateInPast } from '~/lib/utils/datetime_utility'; + +export const TOTAL_DAYS_TO_SHOW = 365; +export const TODAY = new Date(); +export const START_DATE = getDateInPast(TODAY, TOTAL_DAYS_TO_SHOW); diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql new file mode 100644 index 00000000000..40cef95c2e7 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/fragments/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on InstanceStatisticsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql new file mode 100644 index 00000000000..40cef95c2e7 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on InstanceStatisticsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql new file mode 100644 index 00000000000..f14c2658674 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/instance_statistics_count.query.graphql @@ -0,0 +1,34 @@ +#import "../fragments/count.fragment.graphql" + +query getInstanceCounts { + projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + ...Count + } + } + groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) { + nodes { + ...Count + } + } + users: instanceStatisticsMeasurements(identifier: USERS, first: 1) { + nodes { + ...Count + } + } + issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) { + nodes { + ...Count + } + } + mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + ...Count + } + } + pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + ...Count + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql new file mode 100644 index 00000000000..3bf40403f91 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql @@ -0,0 +1,76 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "./count.fragment.graphql" + +query pipelineStats( + $firstTotal: Int + $firstSucceeded: Int + $firstFailed: Int + $firstCanceled: Int + $firstSkipped: Int + $endCursorTotal: String + $endCursorSucceeded: String + $endCursorFailed: String + $endCursorCanceled: String + $endCursorSkipped: String +) { + pipelinesTotal: instanceStatisticsMeasurements( + identifier: PIPELINES + first: $firstTotal + after: $endCursorTotal + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesSucceeded: instanceStatisticsMeasurements( + identifier: PIPELINES_SUCCEEDED + first: $firstSucceeded + after: $endCursorSucceeded + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesFailed: instanceStatisticsMeasurements( + identifier: PIPELINES_FAILED + first: $firstFailed + after: $endCursorFailed + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesCanceled: instanceStatisticsMeasurements( + identifier: PIPELINES_CANCELED + first: $firstCanceled + after: $endCursorCanceled + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } + pipelinesSkipped: instanceStatisticsMeasurements( + identifier: PIPELINES_SKIPPED + first: $firstSkipped + after: $endCursorSkipped + ) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql new file mode 100644 index 00000000000..6235e36eb89 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/graphql/queries/users.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getUsersCount($first: Int, $after: String) { + users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/instance_statistics/index.js b/app/assets/javascripts/analytics/instance_statistics/index.js new file mode 100644 index 00000000000..0d7dcf6ace8 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import InstanceStatisticsApp from './components/app.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.getElementById('js-instance-statistics-app'); + + if (!el) return false; + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(InstanceStatisticsApp); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/instance_statistics/utils.js b/app/assets/javascripts/analytics/instance_statistics/utils.js new file mode 100644 index 00000000000..907482c0c72 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/utils.js @@ -0,0 +1,69 @@ +import { masks } from 'dateformat'; +import { mapKeys, mapValues, pick, sortBy } from 'lodash'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +const { isoDate } = masks; + +/** + * Takes an array of items and returns one item per month with the average of the `count`s from that month + * @param {Array} items + * @param {Number} items[index].count value to be averaged + * @param {String} items[index].recordedAt item dateTime time stamp to be collected into a month + * @param {Object} options + * @param {Object} options.shouldRound an option to specify whether the retuned averages should be rounded + * @return {Array} items collected into [month, average], + * where month is a dateTime string representing the first of the given month + * and average is the average of the count + */ +export function getAverageByMonth(items = [], options = {}) { + const { shouldRound = false } = options; + const itemsMap = items.reduce((memo, item) => { + const { count, recordedAt } = item; + const date = new Date(recordedAt); + const month = formatDate(new Date(date.getFullYear(), date.getMonth(), 1), isoDate); + if (memo[month]) { + const { sum, recordCount } = memo[month]; + return { ...memo, [month]: { sum: sum + count, recordCount: recordCount + 1 } }; + } + + return { ...memo, [month]: { sum: count, recordCount: 1 } }; + }, {}); + + return Object.keys(itemsMap).map(month => { + const { sum, recordCount } = itemsMap[month]; + const avg = sum / recordCount; + if (shouldRound) { + return [month, Math.round(avg)]; + } + + return [month, avg]; + }); +} + +/** + * Extracts values given a data set and a set of keys + * @example + * const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; + * extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' } + * @param {Object} data set to extract values from + * @param {Array} dataKeys keys describing where to look for values in the data set + * @param {String} replaceKey name key to be replaced in the data set + * @param {String} nestedKey key nested in the data set to be extracted, + * this is also used to rename the newly created data set + * @return {Object} the newly created data set with the extracted values + */ +export function extractValues(data, dataKeys = [], replaceKey, nestedKey) { + return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) => + key.replace(replaceKey, nestedKey), + ); +} + +/** + * Creates a new array of items sorted by the date string of each item + * @param {Array} items [description] + * @param {String} items[0] date string + * @return {Array} the new sorted array. + */ +export function sortByDate(items = []) { + return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime()); +} diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue new file mode 100644 index 00000000000..cee186c057c --- /dev/null +++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue @@ -0,0 +1,80 @@ +<script> +import { + GlCard, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlLink, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; + +export default { + name: 'MetricCard', + components: { + GlCard, + GlSkeletonLoading, + GlLink, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + title: { + type: String, + required: true, + }, + metrics: { + type: Array, + required: true, + }, + isLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + valueText(metric) { + const { value = null, unit = null } = metric; + if (!value || value === '-') return '-'; + return unit && value ? `${value} ${unit}` : value; + }, + }, +}; +</script> +<template> + <gl-card> + <template #header> + <strong ref="title">{{ title }}</strong> + </template> + <template #default> + <gl-skeleton-loading v-if="isLoading" class="gl-h-auto gl-py-3" /> + <div v-else ref="metricsWrapper" class="gl-display-flex"> + <div + v-for="metric in metrics" + :key="metric.key" + ref="metricItem" + class="js-metric-card-item gl-flex-grow-1 gl-text-center" + > + <gl-link v-if="metric.link" :href="metric.link"> + <h3 class="gl-my-2 gl-text-blue-700">{{ valueText(metric) }}</h3> + </gl-link> + <h3 v-else class="gl-my-2">{{ valueText(metric) }}</h3> + <p class="text-secondary gl-font-sm gl-mb-2"> + {{ metric.label }} + <span v-if="metric.tooltipText"> + + <gl-icon + v-gl-tooltip="{ title: metric.tooltipText }" + :size="14" + class="gl-vertical-align-middle" + name="question" + data-testid="tooltip" + /> + </span> + </p> + </div> + </div> + </template> + </gl-card> +</template> |