diff options
Diffstat (limited to 'app/assets/javascripts/analytics/usage_trends')
15 files changed, 945 insertions, 0 deletions
diff --git a/app/assets/javascripts/analytics/usage_trends/components/app.vue b/app/assets/javascripts/analytics/usage_trends/components/app.vue new file mode 100644 index 00000000000..c6436160ea2 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/app.vue @@ -0,0 +1,48 @@ +<script> +import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; +import ChartsConfig from './charts_config'; +import ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; +import UsageCounts from './usage_counts.vue'; +import UsageTrendsCountChart from './usage_trends_count_chart.vue'; +import UsersChart from './users_chart.vue'; + +export default { + name: 'UsageTrendsApp', + components: { + UsageCounts, + UsageTrendsCountChart, + UsersChart, + ProjectsAndGroupsChart, + }, + TOTAL_DAYS_TO_SHOW, + START_DATE, + TODAY, + configs: ChartsConfig, +}; +</script> + +<template> + <div> + <usage-counts /> + <users-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <projects-and-groups-chart + :start-date="$options.START_DATE" + :end-date="$options.TODAY" + :total-data-points="$options.TOTAL_DAYS_TO_SHOW" + /> + <usage-trends-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/usage_trends/components/charts_config.js b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js new file mode 100644 index 00000000000..b6b440b710f --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/charts_config.js @@ -0,0 +1,73 @@ +import { s__, __, sprintf } from '~/locale'; +import query from '../graphql/queries/usage_count.query.graphql'; + +const noDataMessage = s__('UsageTrends|No data available.'); + +export default [ + { + loadChartError: sprintf( + s__('UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.'), + ), + noDataMessage, + chartTitle: s__('UsageTrends|Pipelines'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: s__('UsageTrends|Pipelines total'), + identifier: 'PIPELINES', + loadError: sprintf(s__('UsageTrends|There was an error fetching the total pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines succeeded'), + identifier: 'PIPELINES_SUCCEEDED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the successful pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines failed'), + identifier: 'PIPELINES_FAILED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the failed pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines canceled'), + identifier: 'PIPELINES_CANCELED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the cancelled pipelines')), + }, + { + query, + title: s__('UsageTrends|Pipelines skipped'), + identifier: 'PIPELINES_SKIPPED', + loadError: sprintf(s__('UsageTrends|There was an error fetching the skipped pipelines')), + }, + ], + }, + { + loadChartError: sprintf( + s__( + 'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.', + ), + ), + noDataMessage, + chartTitle: s__('UsageTrends|Issues & Merge Requests'), + yAxisTitle: s__('UsageTrends|Items'), + xAxisTitle: s__('UsageTrends|Month'), + queries: [ + { + query, + title: __('Issues'), + identifier: 'ISSUES', + loadError: sprintf(s__('UsageTrends|There was an error fetching the issues')), + }, + { + query, + title: __('Merge requests'), + identifier: 'MERGE_REQUESTS', + loadError: sprintf(s__('UsageTrends|There was an error fetching the merge requests')), + }, + ], + }, +]; diff --git a/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue new file mode 100644 index 00000000000..66aa939938e --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue @@ -0,0 +1,224 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import { s__, __ } from '~/locale'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +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__('UsageTrends|Total projects & groups'), + xAxisTitle: __('Month'), + loadChartError: s__( + 'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.', + ), + loadProjectsDataError: s__('UsageTrends|There was an error while loading the projects'), + loadGroupsDataError: s__('UsageTrends|There was an error while loading the groups'), + noDataMessage: s__('UsageTrends|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__('UsageTrends|Total projects'), + data: this.projectChartData, + }, + { + name: s__('UsageTrends|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> diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue new file mode 100644 index 00000000000..9a0a4f61a74 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue @@ -0,0 +1,64 @@ +<script> +import * as Sentry from '@sentry/browser'; +import MetricCard from '~/analytics/shared/components/metric_card.vue'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; +import { s__ } from '~/locale'; +import usageTrendsCountQuery from '../graphql/queries/usage_trends_count.query.graphql'; + +const defaultPrecision = 0; + +export default { + name: 'UsageCounts', + components: { + MetricCard, + }, + data() { + return { + counts: [], + }; + }, + apollo: { + counts: { + query: usageTrendsCountQuery, + 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__('UsageTrends|Users'), + projects: s__('UsageTrends|Projects'), + groups: s__('UsageTrends|Groups'), + issues: s__('UsageTrends|Issues'), + mergeRequests: s__('UsageTrends|Merge Requests'), + pipelines: s__('UsageTrends|Pipelines'), + }, + loadCountsError: s__('Could not load usage counts. Please refresh the page to try again.'), + }, +}; +</script> + +<template> + <metric-card + :title="__('Usage Trends')" + :metrics="counts" + :is-loading="$apollo.queries.counts.loading" + class="gl-mt-4" + /> +</template> diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue new file mode 100644 index 00000000000..8d7761694d1 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue @@ -0,0 +1,206 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlLineChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; +import { some, every } from 'lodash'; +import { + differenceInMonths, + formatDateAsMonth, + getDayDifference, +} from '~/lib/utils/datetime_utility'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +import { TODAY, START_DATE } from '../constants'; +import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils'; + +const QUERY_DATA_KEY = 'usageTrendsMeasurements'; + +export default { + name: 'UsageTrendsCountChart', + 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/usage_trends/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue new file mode 100644 index 00000000000..09dfcddcb73 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -0,0 +1,143 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import * as Sentry from '@sentry/browser'; +import produce from 'immer'; +import { sortBy } from 'lodash'; +import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; +import { __ } from '~/locale'; +import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; +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/usage_trends/constants.js b/app/assets/javascripts/analytics/usage_trends/constants.js new file mode 100644 index 00000000000..846c0ef408b --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/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/usage_trends/graphql/fragments/count.fragment.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql new file mode 100644 index 00000000000..2bde5973600 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql @@ -0,0 +1,4 @@ +fragment Count on UsageTrendsMeasurement { + count + recordedAt +} diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql new file mode 100644 index 00000000000..b1249cc9480 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getGroupsCount($first: Int, $after: String) { + groups: usageTrendsMeasurements(identifier: GROUPS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql new file mode 100644 index 00000000000..2e10b6cce3e --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getProjectsCount($first: Int, $after: String) { + projects: usageTrendsMeasurements(identifier: PROJECTS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql new file mode 100644 index 00000000000..2a5546efb68 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql @@ -0,0 +1,13 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" +#import "../fragments/count.fragment.graphql" + +query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) { + usageTrendsMeasurements(identifier: $identifier, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql new file mode 100644 index 00000000000..8cadcfae380 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql @@ -0,0 +1,34 @@ +#import "../fragments/count.fragment.graphql" + +query getInstanceCounts { + projects: usageTrendsMeasurements(identifier: PROJECTS, first: 1) { + nodes { + ...Count + } + } + groups: usageTrendsMeasurements(identifier: GROUPS, first: 1) { + nodes { + ...Count + } + } + users: usageTrendsMeasurements(identifier: USERS, first: 1) { + nodes { + ...Count + } + } + issues: usageTrendsMeasurements(identifier: ISSUES, first: 1) { + nodes { + ...Count + } + } + mergeRequests: usageTrendsMeasurements(identifier: MERGE_REQUESTS, first: 1) { + nodes { + ...Count + } + } + pipelines: usageTrendsMeasurements(identifier: PIPELINES, first: 1) { + nodes { + ...Count + } + } +} diff --git a/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql b/app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql new file mode 100644 index 00000000000..7c02ac49a42 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/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: usageTrendsMeasurements(identifier: USERS, first: $first, after: $after) { + nodes { + ...Count + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/analytics/usage_trends/index.js b/app/assets/javascripts/analytics/usage_trends/index.js new file mode 100644 index 00000000000..d1880b09f15 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import UsageTrendsApp from './components/app.vue'; + +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export default () => { + const el = document.getElementById('js-usage-trends-app'); + + if (!el) return false; + + return new Vue({ + el, + apolloProvider, + render(h) { + return h(UsageTrendsApp); + }, + }); +}; diff --git a/app/assets/javascripts/analytics/usage_trends/utils.js b/app/assets/javascripts/analytics/usage_trends/utils.js new file mode 100644 index 00000000000..91907877ed6 --- /dev/null +++ b/app/assets/javascripts/analytics/usage_trends/utils.js @@ -0,0 +1,68 @@ +import { masks } from 'dateformat'; +import { get } 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]; + }); +} + +/** + * Takes an array of usage counts and returns the last item in the list + * @param {Array} arr array of usage counts in the form { count: Number, recordedAt: date String } + * @return {String} the 'recordedAt' value of the earliest item + */ +export const getEarliestDate = (arr = []) => { + const len = arr.length; + return get(arr, `[${len - 1}].recordedAt`, null); +}; + +/** + * Takes an array of queries and produces an object with the query identifier as key + * and a supplied defaultValue as its value + * @param {Array} queries array of chart query configs, + * see ./analytics/usage_trends/components/charts_config.js + * @param {any} defaultValue value to set each identifier to + * @return {Object} key value pair of the form { queryIdentifier: defaultValue } + */ +export const generateDataKeys = (queries, defaultValue) => + queries.reduce( + (acc, { identifier }) => ({ + ...acc, + [identifier]: defaultValue, + }), + {}, + ); |