summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/analytics
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/analytics')
-rw-r--r--app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue (renamed from app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue)23
-rw-r--r--app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js (renamed from app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js)18
-rw-r--r--app/assets/javascripts/analytics/shared/components/daterange.vue121
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue80
-rw-r--r--app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue241
-rw-r--r--app/assets/javascripts/analytics/shared/constants.js12
-rw-r--r--app/assets/javascripts/analytics/shared/graphql/projects.query.graphql22
-rw-r--r--app/assets/javascripts/analytics/shared/utils.js4
-rw-r--r--app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue32
9 files changed, 450 insertions, 103 deletions
diff --git a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue
index c0ad814172d..7c14cf3767f 100644
--- a/app/assets/javascripts/analytics/devops_report/components/usage_ping_disabled.vue
+++ b/app/assets/javascripts/analytics/devops_report/components/service_ping_disabled.vue
@@ -25,28 +25,33 @@ export default {
};
</script>
<template>
- <gl-empty-state class="js-empty-state" :title="__('Usage ping is off')" :svg-path="svgPath">
+ <gl-empty-state :title="s__('ServicePing|Service ping is off')" :svg-path="svgPath">
<template #description>
<gl-sprintf
v-if="!isAdmin"
:message="
- __(
- 'To view instance-level analytics, ask an admin to turn on %{docLinkStart}usage ping%{docLinkEnd}.',
+ s__(
+ 'ServicePing|To view instance-level analytics, ask an admin to turn on %{docLinkStart}service ping%{docLinkEnd}.',
)
"
>
<template #docLink="{ content }">
- <gl-link :href="docsLink" target="_blank">{{ content }}</gl-link>
+ <gl-link :href="docsLink" target="_blank" data-testid="docs-link">{{ content }}</gl-link>
</template>
</gl-sprintf>
- <template v-else
- ><p>
- {{ __('Turn on usage ping to review instance-level analytics.') }}
+ <template v-else>
+ <p>
+ {{ s__('ServicePing|Turn on service ping to review instance-level analytics.') }}
</p>
- <gl-button category="primary" variant="success" :href="primaryButtonPath">
- {{ __('Turn on usage ping') }}</gl-button
+ <gl-button
+ category="primary"
+ variant="success"
+ :href="primaryButtonPath"
+ data-testid="power-on-button"
>
+ {{ s__('ServicePing|Turn on service ping') }}
+ </gl-button>
</template>
</template>
</gl-empty-state>
diff --git a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js
index 0131407e723..63b36f35247 100644
--- a/app/assets/javascripts/analytics/devops_report/devops_score_disabled_usage_ping.js
+++ b/app/assets/javascripts/analytics/devops_report/devops_score_disabled_service_ping.js
@@ -1,27 +1,33 @@
import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
import UserCallout from '~/user_callout';
-import UsagePingDisabled from './components/usage_ping_disabled.vue';
+import ServicePingDisabled from './components/service_ping_disabled.vue';
export default () => {
// eslint-disable-next-line no-new
new UserCallout();
- const emptyStateContainer = document.getElementById('js-devops-usage-ping-disabled');
+ const emptyStateContainer = document.getElementById('js-devops-service-ping-disabled');
if (!emptyStateContainer) return false;
- const { emptyStateSvgPath, enableUsagePingLink, docsLink, isAdmin } = emptyStateContainer.dataset;
+ const {
+ isAdmin,
+ emptyStateSvgPath,
+ enableServicePingPath,
+ docsLink,
+ } = emptyStateContainer.dataset;
return new Vue({
el: emptyStateContainer,
provide: {
- isAdmin: Boolean(isAdmin),
+ isAdmin: parseBoolean(isAdmin),
svgPath: emptyStateSvgPath,
- primaryButtonPath: enableUsagePingLink,
+ primaryButtonPath: enableServicePingPath,
docsLink,
},
render(h) {
- return h(UsagePingDisabled);
+ return h(ServicePingDisabled);
},
});
};
diff --git a/app/assets/javascripts/analytics/shared/components/daterange.vue b/app/assets/javascripts/analytics/shared/components/daterange.vue
new file mode 100644
index 00000000000..a5b9c40b9c9
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/daterange.vue
@@ -0,0 +1,121 @@
+<script>
+import { GlDaterangePicker, GlSprintf, GlIcon, GlTooltipDirective } from '@gitlab/ui';
+import { getDayDifference } from '~/lib/utils/datetime_utility';
+import { __, sprintf } from '~/locale';
+import { OFFSET_DATE_BY_ONE } from '../constants';
+
+export default {
+ components: {
+ GlDaterangePicker,
+ GlSprintf,
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ show: {
+ type: Boolean,
+ required: false,
+ default: true,
+ },
+ startDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ endDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ minDate: {
+ type: Date,
+ required: false,
+ default: null,
+ },
+ maxDate: {
+ type: Date,
+ required: false,
+ default() {
+ return new Date();
+ },
+ },
+ maxDateRange: {
+ type: Number,
+ required: false,
+ default: 0,
+ },
+ includeSelectedDate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ maxDateRangeTooltip: sprintf(
+ __(
+ 'Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days.',
+ ),
+ {
+ maxDateRange: this.maxDateRange,
+ },
+ ),
+ };
+ },
+ computed: {
+ dateRange: {
+ get() {
+ return { startDate: this.startDate, endDate: this.endDate };
+ },
+ set({ startDate, endDate }) {
+ this.$emit('change', { startDate, endDate });
+ },
+ },
+ numberOfDays() {
+ const dayDifference = getDayDifference(this.startDate, this.endDate);
+ return this.includeSelectedDate ? dayDifference + OFFSET_DATE_BY_ONE : dayDifference;
+ },
+ },
+};
+</script>
+<template>
+ <div
+ v-if="show"
+ class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row align-items-lg-center justify-content-lg-end"
+ >
+ <gl-daterange-picker
+ v-model="dateRange"
+ class="d-flex flex-column flex-lg-row"
+ :default-start-date="startDate"
+ :default-end-date="endDate"
+ :default-min-date="minDate"
+ :max-date-range="maxDateRange"
+ :default-max-date="maxDate"
+ :same-day-selection="includeSelectedDate"
+ theme="animate-picker"
+ start-picker-class="js-daterange-picker-from gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-lg-align-items-center gl-lg-mr-3 gl-mb-2 gl-lg-mb-0"
+ end-picker-class="js-daterange-picker-to d-flex flex-column flex-lg-row align-items-lg-center"
+ label-class="gl-mb-2 gl-lg-mb-0"
+ />
+ <div
+ v-if="maxDateRange"
+ class="daterange-indicator d-flex flex-row flex-lg-row align-items-flex-start align-items-lg-center"
+ >
+ <span class="number-of-days pl-2 pr-1">
+ <gl-sprintf :message="n__('1 day selected', '%d days selected', numberOfDays)">
+ <template #numberOfDays>{{ numberOfDays }}</template>
+ </gl-sprintf>
+ </span>
+ <gl-icon
+ v-gl-tooltip
+ data-testid="helper-icon"
+ :title="maxDateRangeTooltip"
+ name="question"
+ :size="14"
+ class="text-secondary"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue
deleted file mode 100644
index e6e12821bec..00000000000
--- a/app/assets/javascripts/analytics/shared/components/metric_card.vue
+++ /dev/null
@@ -1,80 +0,0 @@
-<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 class="gl-mb-5">
- <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">
- &nbsp;
- <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>
diff --git a/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
new file mode 100644
index 00000000000..a490111e13b
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/components/projects_dropdown_filter.vue
@@ -0,0 +1,241 @@
+<script>
+import {
+ GlIcon,
+ GlLoadingIcon,
+ GlAvatar,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+} from '@gitlab/ui';
+import { debounce } from 'lodash';
+import { filterBySearchTerm } from '~/analytics/shared/utils';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
+import { n__, s__, __ } from '~/locale';
+import getProjects from '../graphql/projects.query.graphql';
+
+export default {
+ name: 'ProjectsDropdownFilter',
+ components: {
+ GlIcon,
+ GlLoadingIcon,
+ GlAvatar,
+ GlDropdown,
+ GlDropdownSectionHeader,
+ GlDropdownItem,
+ GlSearchBoxByType,
+ },
+ props: {
+ groupId: {
+ type: Number,
+ required: true,
+ },
+ groupNamespace: {
+ type: String,
+ required: true,
+ },
+ multiSelect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ label: {
+ type: String,
+ required: false,
+ default: s__('CycleAnalytics|project dropdown filter'),
+ },
+ queryParams: {
+ type: Object,
+ required: false,
+ default: () => ({}),
+ },
+ defaultProjects: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ },
+ data() {
+ return {
+ loading: true,
+ projects: [],
+ selectedProjects: this.defaultProjects || [],
+ searchTerm: '',
+ isDirty: false,
+ };
+ },
+ computed: {
+ selectedProjectsLabel() {
+ if (this.selectedProjects.length === 1) {
+ return this.selectedProjects[0].name;
+ } else if (this.selectedProjects.length > 1) {
+ return n__(
+ 'CycleAnalytics|Project selected',
+ 'CycleAnalytics|%d projects selected',
+ this.selectedProjects.length,
+ );
+ }
+
+ return this.selectedProjectsPlaceholder;
+ },
+ selectedProjectsPlaceholder() {
+ return this.multiSelect ? __('Select projects') : __('Select a project');
+ },
+ isOnlyOneProjectSelected() {
+ return this.selectedProjects.length === 1;
+ },
+ selectedProjectIds() {
+ return this.selectedProjects.map((p) => p.id);
+ },
+ availableProjects() {
+ return filterBySearchTerm(this.projects, this.searchTerm);
+ },
+ noResultsAvailable() {
+ const { loading, availableProjects } = this;
+ return !loading && !availableProjects.length;
+ },
+ },
+ watch: {
+ searchTerm() {
+ this.search();
+ },
+ },
+ mounted() {
+ this.search();
+ },
+ methods: {
+ search: debounce(function debouncedSearch() {
+ this.fetchData();
+ }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
+ getSelectedProjects(selectedProject, isMarking) {
+ return isMarking
+ ? this.selectedProjects.concat([selectedProject])
+ : this.selectedProjects.filter((project) => project.id !== selectedProject.id);
+ },
+ singleSelectedProject(selectedObj, isMarking) {
+ return isMarking ? [selectedObj] : [];
+ },
+ setSelectedProjects(selectedObj, isMarking) {
+ this.selectedProjects = this.multiSelect
+ ? this.getSelectedProjects(selectedObj, isMarking)
+ : this.singleSelectedProject(selectedObj, isMarking);
+ },
+ onClick({ project, isSelected }) {
+ this.setSelectedProjects(project, !isSelected);
+ this.$emit('selected', this.selectedProjects);
+ },
+ onMultiSelectClick({ project, isSelected }) {
+ this.setSelectedProjects(project, !isSelected);
+ this.isDirty = true;
+ },
+ onSelected(ev) {
+ if (this.multiSelect) {
+ this.onMultiSelectClick(ev);
+ } else {
+ this.onClick(ev);
+ }
+ },
+ onHide() {
+ if (this.multiSelect && this.isDirty) {
+ this.$emit('selected', this.selectedProjects);
+ }
+ this.searchTerm = '';
+ this.isDirty = false;
+ },
+ fetchData() {
+ this.loading = true;
+
+ return this.$apollo
+ .query({
+ query: getProjects,
+ variables: {
+ groupFullPath: this.groupNamespace,
+ search: this.searchTerm,
+ ...this.queryParams,
+ },
+ })
+ .then((response) => {
+ const {
+ data: {
+ group: {
+ projects: { nodes },
+ },
+ },
+ } = response;
+
+ this.loading = false;
+ this.projects = nodes;
+ });
+ },
+ isProjectSelected(id) {
+ return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
+ },
+ getEntityId(project) {
+ return getIdFromGraphQLId(project.id);
+ },
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ ref="projectsDropdown"
+ class="dropdown dropdown-projects"
+ toggle-class="gl-shadow-none"
+ @hide="onHide"
+ >
+ <template #button-content>
+ <div class="gl-display-flex gl-flex-grow-1">
+ <gl-avatar
+ v-if="isOnlyOneProjectSelected"
+ :src="selectedProjects[0].avatarUrl"
+ :entity-id="getEntityId(selectedProjects[0])"
+ :entity-name="selectedProjects[0].name"
+ :size="16"
+ shape="rect"
+ :alt="selectedProjects[0].name"
+ class="gl-display-inline-flex gl-vertical-align-middle gl-mr-2"
+ />
+ {{ selectedProjectsLabel }}
+ </div>
+ <gl-icon class="gl-ml-2" name="chevron-down" />
+ </template>
+ <template #header>
+ <gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
+ <gl-search-box-by-type v-model.trim="searchTerm" />
+ </template>
+ <gl-dropdown-item
+ v-for="project in availableProjects"
+ :key="project.id"
+ :is-check-item="true"
+ :is-checked="isProjectSelected(project.id)"
+ @click.native.capture.stop="
+ onSelected({ project, isSelected: isProjectSelected(project.id) })
+ "
+ >
+ <div class="gl-display-flex">
+ <gl-avatar
+ class="gl-mr-2 vertical-align-middle"
+ :alt="project.name"
+ :size="16"
+ :entity-id="getEntityId(project)"
+ :entity-name="project.name"
+ :src="project.avatarUrl"
+ shape="rect"
+ />
+ <div>
+ <div data-testid="project-name">{{ project.name }}</div>
+ <div class="gl-text-gray-500" data-testid="project-full-path">
+ {{ project.fullPath }}
+ </div>
+ </div>
+ </div>
+ </gl-dropdown-item>
+ <gl-dropdown-item v-show="noResultsAvailable" class="gl-pointer-events-none text-secondary">{{
+ __('No matching results')
+ }}</gl-dropdown-item>
+ <gl-dropdown-item v-if="loading">
+ <gl-loading-icon size="lg" />
+ </gl-dropdown-item>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/analytics/shared/constants.js b/app/assets/javascripts/analytics/shared/constants.js
new file mode 100644
index 00000000000..44d9b4b4262
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/constants.js
@@ -0,0 +1,12 @@
+import { masks } from 'dateformat';
+
+export const DATE_RANGE_LIMIT = 180;
+export const OFFSET_DATE_BY_ONE = 1;
+export const PROJECTS_PER_PAGE = 50;
+
+const { isoDate, mediumDate } = masks;
+export const dateFormats = {
+ isoDate,
+ defaultDate: mediumDate,
+ defaultDateTime: 'mmm d, yyyy h:MMtt',
+};
diff --git a/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
new file mode 100644
index 00000000000..63e95d6804c
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/graphql/projects.query.graphql
@@ -0,0 +1,22 @@
+query getGroupProjects(
+ $groupFullPath: ID!
+ $search: String!
+ $first: Int!
+ $includeSubgroups: Boolean = false
+) {
+ group(fullPath: $groupFullPath) {
+ projects(
+ search: $search
+ first: $first
+ includeSubgroups: $includeSubgroups
+ sort: SIMILARITY
+ ) {
+ nodes {
+ id
+ name
+ avatarUrl
+ fullPath
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/analytics/shared/utils.js b/app/assets/javascripts/analytics/shared/utils.js
new file mode 100644
index 00000000000..84189b675f2
--- /dev/null
+++ b/app/assets/javascripts/analytics/shared/utils.js
@@ -0,0 +1,4 @@
+export const filterBySearchTerm = (data = [], searchTerm = '', filterByKey = 'name') => {
+ if (!searchTerm?.length) return data;
+ return data.filter((item) => item[filterByKey].toLowerCase().includes(searchTerm.toLowerCase()));
+};
diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
index 0b4fa879b03..1eb4832a2a3 100644
--- a/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
+++ b/app/assets/javascripts/analytics/usage_trends/components/usage_counts.vue
@@ -1,5 +1,6 @@
<script>
-import MetricCard from '~/analytics/shared/components/metric_card.vue';
+import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui';
+import { GlSingleStat } from '@gitlab/ui/dist/charts';
import createFlash from '~/flash';
import { number } from '~/lib/utils/unit_format';
import { s__ } from '~/locale';
@@ -10,7 +11,8 @@ const defaultPrecision = 0;
export default {
name: 'UsageCounts',
components: {
- MetricCard,
+ GlSkeletonLoading,
+ GlSingleStat,
},
data() {
return {
@@ -56,10 +58,24 @@ export default {
</script>
<template>
- <metric-card
- :title="__('Usage Trends')"
- :metrics="counts"
- :is-loading="$apollo.queries.counts.loading"
- class="gl-mt-4"
- />
+ <div>
+ <h2>
+ {{ __('Usage Trends') }}
+ </h2>
+ <div
+ class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-my-6 gl-align-items-flex-start"
+ >
+ <gl-skeleton-loading v-if="$apollo.queries.counts.loading" />
+ <template v-else>
+ <gl-single-stat
+ v-for="count in counts"
+ :key="count.key"
+ class="gl-pr-9 gl-my-4 gl-md-mt-0 gl-md-mb-0"
+ :value="`${count.value}`"
+ :title="count.label"
+ :should-animate="true"
+ />
+ </template>
+ </div>
+ </div>
</template>