Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/analytics/usage_trends')
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/app.vue48
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/charts_config.js73
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/projects_and_groups_chart.vue224
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue64
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue206
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/users_chart.vue143
-rw-r--r--app/assets/javascripts/analytics/usage_trends/constants.js5
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/fragments/count.fragment.graphql4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/groups.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/projects.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_count.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/usage_trends_count.query.graphql34
-rw-r--r--app/assets/javascripts/analytics/usage_trends/graphql/queries/users.query.graphql13
-rw-r--r--app/assets/javascripts/analytics/usage_trends/index.js24
-rw-r--r--app/assets/javascripts/analytics/usage_trends/utils.js68
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,
+ }),
+ {},
+ );