diff options
Diffstat (limited to 'app/assets/javascripts/analytics/instance_statistics/components')
5 files changed, 538 insertions, 218 deletions
diff --git a/app/assets/javascripts/analytics/instance_statistics/components/app.vue b/app/assets/javascripts/analytics/instance_statistics/components/app.vue index 7aa5c98aa0b..8df4d2e2524 100644 --- a/app/assets/javascripts/analytics/instance_statistics/components/app.vue +++ b/app/assets/javascripts/analytics/instance_statistics/components/app.vue @@ -1,19 +1,23 @@ <script> import InstanceCounts from './instance_counts.vue'; -import PipelinesChart from './pipelines_chart.vue'; +import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; import UsersChart from './users_chart.vue'; +import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; +import ChartsConfig from './charts_config'; import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; export default { name: 'InstanceStatisticsApp', components: { InstanceCounts, - PipelinesChart, + InstanceStatisticsCountChart, UsersChart, + ProjectsAndGroupsChart, }, TOTAL_DAYS_TO_SHOW, START_DATE, TODAY, + configs: ChartsConfig, }; </script> @@ -25,6 +29,20 @@ export default { :end-date="$options.TODAY" :total-data-points="$options.TOTAL_DAYS_TO_SHOW" /> - <pipelines-chart /> + <projects-and-groups-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <instance-statistics-count-chart + v-for="chartOptions in $options.configs" + :key="chartOptions.chartTitle" + :queries="chartOptions.queries" + :x-axis-title="chartOptions.xAxisTitle" + :y-axis-title="chartOptions.yAxisTitle" + :load-chart-error-message="chartOptions.loadChartError" + :no-data-message="chartOptions.noDataMessage" + :chart-title="chartOptions.chartTitle" + /> </div> </template> diff --git a/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js new file mode 100644 index 00000000000..6fba3c56cfe --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/charts_config.js @@ -0,0 +1,87 @@ +import { s__, __, sprintf } from '~/locale'; +import query from '../graphql/queries/instance_count.query.graphql'; + +const noDataMessage = s__('InstanceStatistics|No data available.'); + +export default [ + { + loadChartError: sprintf( + s__( + 'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('InstanceStatistics|Pipelines'), + yAxisTitle: s__('InstanceStatistics|Items'), + xAxisTitle: s__('InstanceStatistics|Month'), + queries: [ + { + query, + title: s__('InstanceStatistics|Pipelines total'), + identifier: 'PIPELINES', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the total pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines succeeded'), + identifier: 'PIPELINES_SUCCEEDED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the successful pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines failed'), + identifier: 'PIPELINES_FAILED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the failed pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines canceled'), + identifier: 'PIPELINES_CANCELED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the cancelled pipelines'), + ), + }, + { + query, + title: s__('InstanceStatistics|Pipelines skipped'), + identifier: 'PIPELINES_SKIPPED', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the skipped pipelines'), + ), + }, + ], + }, + { + loadChartError: sprintf( + s__( + 'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('InstanceStatistics|Issues & Merge Requests'), + yAxisTitle: s__('InstanceStatistics|Items'), + xAxisTitle: s__('InstanceStatistics|Month'), + queries: [ + { + query, + title: __('Issues'), + identifier: 'ISSUES', + loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')), + }, + { + query, + title: __('Merge requests'), + identifier: 'MERGE_REQUESTS', + loadError: sprintf( + s__('InstanceStatistics|There was an error fetching the merge requests'), + ), + }, + ], + }, +]; diff --git a/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue new file mode 100644 index 00000000000..a9bd1bb2f41 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/instance_statistics_count_chart.vue @@ -0,0 +1,206 @@ +<script> +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import { GlAlert } from '@gitlab/ui'; +import { some, every } from 'lodash'; +import * as Sentry from '~/sentry/wrapper'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { + differenceInMonths, + formatDateAsMonth, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils'; +import { TODAY, START_DATE } from '../constants'; + +const QUERY_DATA_KEY = 'instanceStatisticsMeasurements'; + +export default { + name: 'InstanceStatisticsCountChart', + components: { + GlLineChart, + GlAlert, + ChartSkeletonLoader, + }, + startDate: START_DATE, + endDate: TODAY, + props: { + chartTitle: { + type: String, + required: true, + }, + loadChartErrorMessage: { + type: String, + required: true, + }, + noDataMessage: { + type: String, + required: true, + }, + xAxisTitle: { + type: String, + required: true, + }, + yAxisTitle: { + type: String, + required: true, + }, + queries: { + type: Array, + required: true, + }, + }, + data() { + return { + errors: { ...generateDataKeys(this.queries, '') }, + ...generateDataKeys(this.queries, []), + }; + }, + computed: { + errorMessages() { + return Object.values(this.errors); + }, + isLoading() { + return some(this.$apollo.queries, query => query?.loading); + }, + allQueriesFailed() { + return every(this.errorMessages, message => message.length); + }, + hasLoadingErrors() { + return some(this.errorMessages, message => message.length); + }, + errorMessage() { + // show the generic loading message if all requests fail + return this.allQueriesFailed ? this.loadChartErrorMessage : this.errorMessages.join('\n\n'); + }, + hasEmptyDataSet() { + return this.chartData.every(({ data }) => data.length === 0); + }, + totalDaysToShow() { + return getDayDifference(this.$options.startDate, this.$options.endDate); + }, + chartData() { + const options = { shouldRound: true }; + return this.queries.map(({ identifier, title }) => ({ + name: title, + data: getAverageByMonth(this[identifier]?.nodes, options), + })); + }, + range() { + return { + min: this.$options.startDate, + max: this.$options.endDate, + }; + }, + chartOptions() { + const { endDate, startDate } = this.$options; + return { + xAxis: { + ...this.range, + name: this.xAxisTitle, + type: 'time', + splitNumber: differenceInMonths(startDate, endDate) + 1, + axisLabel: { + interval: 0, + showMinLabel: false, + showMaxLabel: false, + align: 'right', + formatter: formatDateAsMonth, + }, + }, + yAxis: { + name: this.yAxisTitle, + }, + }; + }, + }, + created() { + this.queries.forEach(({ query, identifier, loadError }) => { + this.$apollo.addSmartQuery(identifier, { + query, + variables() { + return { + identifier, + first: this.totalDaysToShow, + after: null, + }; + }, + update(data) { + const { nodes = [], pageInfo } = data[QUERY_DATA_KEY] || {}; + return { + nodes, + pageInfo, + }; + }, + result() { + const { pageInfo, nodes } = this[identifier]; + if (pageInfo?.hasNextPage && this.calculateDaysToFetch(getEarliestDate(nodes)) > 0) { + this.fetchNextPage({ + query: this.$apollo.queries[identifier], + errorMessage: loadError, + pageInfo, + identifier, + }); + } + }, + error(error) { + this.handleError({ + message: loadError, + identifier, + error, + }); + }, + }); + }); + }, + methods: { + calculateDaysToFetch(firstDataPointDate = null) { + return firstDataPointDate + ? Math.max(0, getDayDifference(this.$options.startDate, new Date(firstDataPointDate))) + : 0; + }, + handleError({ identifier, error, message }) { + this.loadingError = true; + this.errors = { ...this.errors, [identifier]: message }; + Sentry.captureException(error); + }, + fetchNextPage({ query, pageInfo, identifier, errorMessage }) { + query + .fetchMore({ + variables: { + identifier, + first: this.calculateDaysToFetch(getEarliestDate(this[identifier].nodes)), + after: pageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const { nodes, ...rest } = fetchMoreResult[QUERY_DATA_KEY]; + const { nodes: previousNodes } = previousResult[QUERY_DATA_KEY]; + return { + [QUERY_DATA_KEY]: { ...rest, nodes: [...previousNodes, ...nodes] }, + }; + }, + }) + .catch(error => this.handleError({ identifier, error, message: errorMessage })); + }, + }, +}; +</script> +<template> + <div> + <h3>{{ chartTitle }}</h3> + <gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ errorMessage }} + </gl-alert> + <div v-if="!allQueriesFailed"> + <chart-skeleton-loader v-if="isLoading" /> + <gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3"> + {{ noDataMessage }} + </gl-alert> + <gl-line-chart + v-else + :option="chartOptions" + :include-legend-avg-max="true" + :data="chartData" + /> + </div> + </div> +</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 deleted file mode 100644 index b16d960402b..00000000000 --- a/app/assets/javascripts/analytics/instance_statistics/components/pipelines_chart.vue +++ /dev/null @@ -1,215 +0,0 @@ -<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/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue new file mode 100644 index 00000000000..e8e35c22fe1 --- /dev/null +++ b/app/assets/javascripts/analytics/instance_statistics/components/projects_and_groups_chart.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlLineChart } 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 { s__, __ } from '~/locale'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import latestGroupsQuery from '../graphql/queries/groups.query.graphql'; +import latestProjectsQuery from '../graphql/queries/projects.query.graphql'; +import { getAverageByMonth } from '../utils'; + +const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime()); + +const averageAndSortData = (data = [], maxDataPoints) => { + const averaged = getAverageByMonth( + data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data, + { shouldRound: true }, + ); + return sortByDate(averaged); +}; + +export default { + name: 'ProjectsAndGroupsChart', + components: { GlAlert, GlLineChart, ChartSkeletonLoader }, + props: { + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: true, + }, + totalDataPoints: { + type: Number, + required: true, + }, + }, + data() { + return { + loadingError: false, + errorMessage: '', + groups: [], + projects: [], + groupsPageInfo: null, + projectsPageInfo: null, + }; + }, + apollo: { + groups: { + query: latestGroupsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.groups?.nodes || []; + }, + result({ data }) { + const { + groups: { pageInfo }, + } = data; + this.groupsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.groups, + pageInfo: this.groupsPageInfo, + dataKey: 'groups', + errorMessage: this.$options.i18n.loadGroupsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadGroupsDataError, + error, + dataKey: 'groups', + }); + }, + }, + projects: { + query: latestProjectsQuery, + variables() { + return { + first: this.totalDataPoints, + after: null, + }; + }, + update(data) { + return data.projects?.nodes || []; + }, + result({ data }) { + const { + projects: { pageInfo }, + } = data; + this.projectsPageInfo = pageInfo; + this.fetchNextPage({ + query: this.$apollo.queries.projects, + pageInfo: this.projectsPageInfo, + dataKey: 'projects', + errorMessage: this.$options.i18n.loadProjectsDataError, + }); + }, + error(error) { + this.handleError({ + message: this.$options.i18n.loadProjectsDataError, + error, + dataKey: 'projects', + }); + }, + }, + }, + i18n: { + yAxisTitle: s__('InstanceStatistics|Total projects & groups'), + xAxisTitle: __('Month'), + loadChartError: s__( + 'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.', + ), + loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'), + loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'), + noDataMessage: s__('InstanceStatistics|No data available.'), + }, + computed: { + isLoadingGroups() { + return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage; + }, + isLoadingProjects() { + return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage; + }, + isLoading() { + return this.isLoadingProjects && this.isLoadingGroups; + }, + groupChartData() { + return averageAndSortData(this.groups, this.totalDataPoints); + }, + projectChartData() { + return averageAndSortData(this.projects, this.totalDataPoints); + }, + hasNoData() { + const { projectChartData, groupChartData } = this; + return Boolean(!projectChartData.length && !groupChartData.length); + }, + options() { + return { + xAxis: { + name: this.$options.i18n.xAxisTitle, + type: 'category', + axisLabel: { + formatter: value => { + return formatDateAsMonth(value); + }, + }, + }, + yAxis: { + name: this.$options.i18n.yAxisTitle, + }, + }; + }, + chartData() { + return [ + { + name: s__('InstanceStatistics|Total projects'), + data: this.projectChartData, + }, + { + name: s__('InstanceStatistics|Total groups'), + data: this.groupChartData, + }, + ]; + }, + }, + methods: { + handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) { + this.loadingError = true; + this.errorMessage = message; + if (!dataKey) { + this.projects = []; + this.groups = []; + } else { + this[dataKey] = []; + } + Sentry.captureException(error); + }, + fetchNextPage({ pageInfo, query, dataKey, errorMessage }) { + if (pageInfo?.hasNextPage) { + query + .fetchMore({ + variables: { first: this.totalDataPoints, after: pageInfo.endCursor }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, newData => { + // eslint-disable-next-line no-param-reassign + newData[dataKey].nodes = [ + ...previousResult[dataKey].nodes, + ...newData[dataKey].nodes, + ]; + }); + return results; + }, + }) + .catch(error => { + this.handleError({ error, message: errorMessage, dataKey }); + }); + } + }, + }, +}; +</script> +<template> + <div> + <h3>{{ $options.i18n.yAxisTitle }}</h3> + <chart-skeleton-loader v-if="isLoading" /> + <gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3"> + {{ $options.i18n.noDataMessage }} + </gl-alert> + <div v-else> + <gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{ + errorMessage + }}</gl-alert> + <gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" /> + </div> + </div> +</template> |