diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-20 14:18:08 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-09-20 14:18:08 +0300 |
commit | 5afcbe03ead9ada87621888a31a62652b10a7e4f (patch) | |
tree | 9918b67a0d0f0bafa6542e839a8be37adf73102d /app/assets/javascripts/ci | |
parent | c97c0201564848c1f53226fe19d71fdcc472f7d0 (diff) |
Add latest changes from gitlab-org/gitlab@16-4-stable-eev16.4.0-rc42
Diffstat (limited to 'app/assets/javascripts/ci')
253 files changed, 19124 insertions, 658 deletions
diff --git a/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue new file mode 100644 index 00000000000..89582e64f3a --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue @@ -0,0 +1,271 @@ +<script> +import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; +import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import JobsTableTabs from '~/ci/jobs_page/components/jobs_table_tabs.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import JobsTableEmptyState from '~/ci/jobs_page/components/jobs_table_empty_state.vue'; +import { createAlert } from '~/alert'; +import { + TOKEN_TYPE_STATUS, + TOKEN_TYPE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + DEFAULT_FIELDS_ADMIN, + RAW_TEXT_WARNING_ADMIN, + JOBS_COUNT_ERROR_MESSAGE, + JOBS_FETCH_ERROR_MSG, + LOADING_ARIA_LABEL, + CANCELABLE_JOBS_ERROR_MSG, +} from './constants'; +import JobsSkeletonLoader from './components/jobs_skeleton_loader.vue'; +import GetAllJobs from './graphql/queries/get_all_jobs.query.graphql'; +import GetAllJobsCount from './graphql/queries/get_all_jobs_count.query.graphql'; +import getCancelableJobs from './graphql/queries/get_cancelable_jobs_count.query.graphql'; + +export default { + i18n: { + jobsCountErrorMsg: JOBS_COUNT_ERROR_MESSAGE, + jobsFetchErrorMsg: JOBS_FETCH_ERROR_MSG, + loadingAriaLabel: LOADING_ARIA_LABEL, + cancelableJobsErrorMsg: CANCELABLE_JOBS_ERROR_MSG, + }, + filterSearchBoxStyles: + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', + components: { + JobsSkeletonLoader, + JobsTableEmptyState, + GlAlert, + JobsFilteredSearch, + JobsTable, + JobsTableTabs, + GlIntersectionObserver, + GlLoadingIcon, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + jobStatuses: { + default: null, + required: false, + }, + url: { + default: '', + required: false, + }, + emptyStateSvgPath: { + default: '', + required: false, + }, + }, + apollo: { + jobs: { + query: GetAllJobs, + variables() { + return this.variables; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data || {}; + return { + list, + pageInfo, + }; + }, + error() { + this.error = this.$options.i18n.jobsFetchErrorMsg; + }, + }, + jobsCount: { + query: GetAllJobsCount, + variables() { + return this.variables; + }, + update(data) { + return data?.jobs?.count || 0; + }, + context: { + isSingleRequest: true, + }, + error() { + this.error = this.$options.i18n.jobsCountErrorMsg; + }, + }, + cancelable: { + query: getCancelableJobs, + update(data) { + this.isCancelable = data.cancelable.count !== 0; + }, + error() { + this.error = this.$options.i18n.cancelableJobsErrorMsg; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + error: '', + count: 0, + scope: null, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + DEFAULT_FIELDS_ADMIN, + isCancelable: false, + jobsCount: null, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search + showEmptyState() { + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); + }, + hasNextPage() { + return this.jobs?.pageInfo?.hasNextPage; + }, + variables() { + return { ...this.validatedQueryString }; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + showFilteredSearch() { + return !this.scope; + }, + showLoadingSpinner() { + return this.loading && this.infiniteScrollingTriggered; + }, + showSkeletonLoader() { + return this.loading && !this.showLoadingSpinner; + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount) { + if (this.scope) return; + + this.count = newCount; + }, + }, + methods: { + updateHistoryAndFetchCount(filterParams = {}) { + this.$apollo.queries.jobsCount.refetch(filterParams); + + updateHistory({ + url: setUrlParams(filterParams, window.location.href, true), + }); + }, + fetchJobsByStatus(scope) { + this.infiniteScrollingTriggered = false; + + if (this.scope === scope) return; + + this.scope = scope; + + if (!this.scope) this.updateHistoryAndFetchCount(); + + this.$apollo.queries.jobs.refetch({ statuses: scope }); + }, + fetchMoreJobs() { + if (!this.loading) { + this.infiniteScrollingTriggered = true; + + const parameters = this.variables; + parameters.after = this.jobs?.pageInfo?.endCursor; + + this.$apollo.queries.jobs.fetchMore({ + variables: parameters, + }); + } + }, + filterJobsBySearch(filters) { + this.infiniteScrollingTriggered = false; + this.filterSearchTriggered = true; + + if (filters.some((filter) => !filter.type)) { + // Raw text input in filtered search does not have a type + // when a user enters raw text we alert them that it is + // not supported and we do not make an additional API call + createAlert({ message: RAW_TEXT_WARNING_ADMIN, type: 'warning' }); + return; + } + + const defaultFilterParams = this.glFeatures.adminJobsFilterRunnerType + ? { statuses: null, runnerTypes: null } + : { statuses: null }; + + const filterParams = filters.reduce((acc, filter) => { + switch (filter.type) { + case TOKEN_TYPE_STATUS: + return { ...acc, statuses: filter.value.data }; + + case TOKEN_TYPE_JOBS_RUNNER_TYPE: + if (this.glFeatures.adminJobsFilterRunnerType) { + return { ...acc, runnerTypes: filter.value.data }; + } + return acc; + + default: + return acc; + } + }, defaultFilterParams); + + this.updateHistoryAndFetchCount(filterParams); + this.$apollo.queries.jobs.refetch(filterParams); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert v-if="error" class="gl-mt-2" variant="danger" dismissible @dismiss="error = ''"> + {{ error }} + </gl-alert> + + <jobs-table-tabs + :all-jobs-count="count" + :loading="loading" + :show-cancel-all-jobs-button="isCancelable" + @fetchJobsByStatus="fetchJobsByStatus" + /> + + <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles"> + <jobs-filtered-search + :query-string="validatedQueryString" + @filterJobsBySearch="filterJobsBySearch" + /> + </div> + + <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" /> + + <jobs-table-empty-state v-else-if="showEmptyState" /> + + <jobs-table + v-else + :jobs="jobs.list" + :table-fields="DEFAULT_FIELDS_ADMIN" + admin + class="gl-table-no-top-border" + /> + + <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon + v-if="showLoadingSpinner" + size="lg" + :aria-label="$options.i18n.loadingAriaLabel" + /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs.vue new file mode 100644 index 00000000000..fb13fd4b03e --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs.vue @@ -0,0 +1,37 @@ +<script> +import { GlButton, GlModalDirective, GlTooltipDirective } from '@gitlab/ui'; +import { CANCEL_JOBS_MODAL_ID, CANCEL_JOBS_BUTTON_TEXT, CANCEL_BUTTON_TOOLTIP } from '../constants'; +import CancelJobsModal from './cancel_jobs_modal.vue'; + +export default { + name: 'CancelJobs', + components: { + GlButton, + CancelJobsModal, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + props: { + url: { + type: String, + required: true, + }, + }, + modalId: CANCEL_JOBS_MODAL_ID, + buttonText: CANCEL_JOBS_BUTTON_TEXT, + buttonTooltip: CANCEL_BUTTON_TOOLTIP, +}; +</script> +<template> + <div> + <gl-button + v-gl-modal="$options.modalId" + v-gl-tooltip="$options.buttonTooltip" + variant="danger" + >{{ $options.buttonText }}</gl-button + > + <cancel-jobs-modal :modal-id="$options.modalId" :url="url" @confirm="$emit('confirm')" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs_modal.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs_modal.vue new file mode 100644 index 00000000000..7c1cd75609a --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cancel_jobs_modal.vue @@ -0,0 +1,66 @@ +<script> +import { GlModal } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { + CANCEL_TEXT, + CANCEL_JOBS_FAILED_TEXT, + CANCEL_JOBS_MODAL_TITLE, + CANCEL_JOBS_WARNING, + PRIMARY_ACTION_TEXT, +} from '../constants'; + +export default { + components: { + GlModal, + }, + props: { + url: { + type: String, + required: true, + }, + modalId: { + type: String, + required: true, + }, + }, + methods: { + onSubmit() { + return axios + .post(this.url) + .then((response) => { + // follow the rediect to refresh the page + redirectTo(response.request.responseURL); // eslint-disable-line import/no-deprecated + }) + .catch((error) => { + createAlert({ + message: CANCEL_JOBS_FAILED_TEXT, + }); + throw error; + }); + }, + }, + primaryAction: { + text: PRIMARY_ACTION_TEXT, + attributes: { variant: 'danger' }, + }, + cancelAction: { + text: CANCEL_TEXT, + }, + CANCEL_JOBS_WARNING, + CANCEL_JOBS_MODAL_TITLE, +}; +</script> + +<template> + <gl-modal + :modal-id="modalId" + :action-primary="$options.primaryAction" + :action-cancel="$options.cancelAction" + :title="$options.CANCEL_JOBS_MODAL_TITLE" + @primary="onSubmit" + > + {{ $options.CANCEL_JOBS_WARNING }} + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue new file mode 100644 index 00000000000..cbb80a5175f --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/project_cell.vue @@ -0,0 +1,28 @@ +<script> +import { GlLink } from '@gitlab/ui'; + +export default { + components: { + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + projectName() { + return this.job.pipeline?.project?.fullPath; + }, + projectUrl() { + return this.job.pipeline?.project?.webUrl; + }, + }, +}; +</script> +<template> + <div class="gl-text-truncate"> + <gl-link :href="projectUrl"> {{ projectName }}</gl-link> + </div> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue new file mode 100644 index 00000000000..a76829aa129 --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/components/cells/runner_cell.vue @@ -0,0 +1,39 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { RUNNER_EMPTY_TEXT, RUNNER_NO_DESCRIPTION } from '../../constants'; + +export default { + i18n: { + emptyRunnerText: RUNNER_EMPTY_TEXT, + noRunnerDescription: RUNNER_NO_DESCRIPTION, + }, + components: { + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + adminUrl() { + return this.job.runner?.adminUrl; + }, + description() { + return this.job.runner?.description + ? this.job.runner.description + : this.$options.i18n.noRunnerDescription; + }, + }, +}; +</script> + +<template> + <div class="gl-text-truncate"> + <gl-link v-if="adminUrl" :href="adminUrl"> + {{ description }} + </gl-link> + <span v-else data-testid="empty-runner-text"> {{ $options.i18n.emptyRunnerText }}</span> + </div> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/components/jobs_skeleton_loader.vue b/app/assets/javascripts/ci/admin/jobs_table/components/jobs_skeleton_loader.vue new file mode 100644 index 00000000000..c305e09af0d --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/components/jobs_skeleton_loader.vue @@ -0,0 +1,26 @@ +<script> +import { GlSkeletonLoader } from '@gitlab/ui'; + +export default { + components: { + GlSkeletonLoader, + }, +}; +</script> + +<template> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> +</template> diff --git a/app/assets/javascripts/ci/admin/jobs_table/constants.js b/app/assets/javascripts/ci/admin/jobs_table/constants.js new file mode 100644 index 00000000000..ff0efdb1f5b --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/constants.js @@ -0,0 +1,35 @@ +import { s__, __ } from '~/locale'; +import { RAW_TEXT_WARNING } from '~/ci/jobs_page/constants'; + +export const JOBS_COUNT_ERROR_MESSAGE = __('There was an error fetching the number of jobs.'); +export const JOBS_FETCH_ERROR_MSG = __('There was an error fetching the jobs.'); +export const LOADING_ARIA_LABEL = __('Loading'); +export const CANCELABLE_JOBS_ERROR_MSG = __('There was an error fetching the cancelable jobs.'); +export const CANCEL_JOBS_MODAL_ID = 'cancel-jobs-modal'; +export const CANCEL_JOBS_MODAL_TITLE = s__('AdminArea|Are you sure?'); +export const CANCEL_JOBS_BUTTON_TEXT = s__('AdminArea|Cancel all jobs'); +export const CANCEL_BUTTON_TOOLTIP = s__('AdminArea|Cancel all running and pending jobs'); +export const CANCEL_TEXT = __('Cancel'); +export const CANCEL_JOBS_FAILED_TEXT = s__('AdminArea|Canceling jobs failed'); +export const PRIMARY_ACTION_TEXT = s__('AdminArea|Yes, proceed'); +export const CANCEL_JOBS_WARNING = s__( + "AdminArea|You're about to cancel all running and pending jobs across this instance. Do you want to proceed?", +); +export const RUNNER_EMPTY_TEXT = __('None'); +export const RUNNER_NO_DESCRIPTION = s__('Runners|No description'); + +/* Admin Table constants */ +/* The field list is based on app/assets/javascripts/jobs/components/table/constants.js */ +export const DEFAULT_FIELDS_ADMIN = [ + { key: 'status', label: __('Status'), columnClass: 'gl-w-15p' }, + { key: 'job', label: __('Job'), columnClass: 'gl-w-20p' }, + { key: 'project', label: __('Project'), columnClass: 'gl-w-20p' }, + { key: 'runner', label: __('Runner'), columnClass: 'gl-w-15p' }, + { key: 'pipeline', label: __('Pipeline'), columnClass: 'gl-w-10p' }, + { key: 'stage', label: __('Stage'), columnClass: 'gl-w-10p' }, + { key: 'name', label: __('Name'), columnClass: 'gl-w-15p' }, + { key: 'duration', label: __('Duration'), columnClass: 'gl-w-15p' }, + { key: 'actions', label: '', columnClass: 'gl-w-10p' }, +]; + +export const RAW_TEXT_WARNING_ADMIN = RAW_TEXT_WARNING; diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/cache_config.js b/app/assets/javascripts/ci/admin/jobs_table/graphql/cache_config.js new file mode 100644 index 00000000000..fd7ee2a6f8c --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/cache_config.js @@ -0,0 +1,62 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Query: { + fields: { + jobs: { + keyArgs: ['statuses'], + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + count: incoming.count, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql new file mode 100644 index 00000000000..89fb1782e46 --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs.query.graphql @@ -0,0 +1,90 @@ +query getAllJobs( + $after: String + $first: Int = 50 + $statuses: [CiJobStatus!] + $runnerTypes: [CiRunnerType!] +) { + jobs(after: $after, first: $first, statuses: $statuses, runnerTypes: $runnerTypes) { + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + runner { + id + description + adminUrl + } + artifacts { + nodes { + id + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + project { + id + fullPath + webUrl + } + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } +} diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql new file mode 100644 index 00000000000..bcb0123e9e3 --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_all_jobs_count.query.graphql @@ -0,0 +1,5 @@ +query getAllJobsCount($statuses: [CiJobStatus!], $runnerTypes: [CiRunnerType!]) { + jobs(statuses: $statuses, runnerTypes: $runnerTypes) { + count + } +} diff --git a/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql new file mode 100644 index 00000000000..9bf5e1449b7 --- /dev/null +++ b/app/assets/javascripts/ci/admin/jobs_table/graphql/queries/get_cancelable_jobs_count.query.graphql @@ -0,0 +1,5 @@ +query getCancelableJobsCount { + cancelable: jobs(statuses: [PENDING, RUNNING]) { + count + } +} diff --git a/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue b/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue deleted file mode 100644 index d2c96b1a201..00000000000 --- a/app/assets/javascripts/ci/artifacts/components/feedback_banner.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import { GlBanner } from '@gitlab/ui'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; -import { - I18N_FEEDBACK_BANNER_TITLE, - I18N_FEEDBACK_BANNER_BODY, - I18N_FEEDBACK_BANNER_BUTTON, - FEEDBACK_URL, -} from '../constants'; - -export default { - components: { - GlBanner, - UserCalloutDismisser, - }, - inject: ['artifactsManagementFeedbackImagePath'], - FEEDBACK_URL, - i18n: { - title: I18N_FEEDBACK_BANNER_TITLE, - body: I18N_FEEDBACK_BANNER_BODY, - button: I18N_FEEDBACK_BANNER_BUTTON, - }, -}; -</script> -<template> - <user-callout-dismisser feature-name="artifacts_management_page_feedback_banner"> - <template #default="{ dismiss, shouldShowCallout }"> - <gl-banner - v-if="shouldShowCallout" - class="gl-mb-6" - :title="$options.i18n.title" - :button-text="$options.i18n.button" - :button-link="$options.FEEDBACK_URL" - :svg-path="artifactsManagementFeedbackImagePath" - @close="dismiss" - > - <p>{{ $options.i18n.body }}</p> - </gl-banner> - </template> - </user-callout-dismisser> -</template> diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index 88334488fdd..e08470c62be 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -48,7 +48,6 @@ import JobCheckbox from './job_checkbox.vue'; import ArtifactsBulkDelete from './artifacts_bulk_delete.vue'; import BulkDeleteModal from './bulk_delete_modal.vue'; import ArtifactsTableRowDetails from './artifacts_table_row_details.vue'; -import FeedbackBanner from './feedback_banner.vue'; const INITIAL_PAGINATION_STATE = { currentPage: INITIAL_CURRENT_PAGE, @@ -76,7 +75,6 @@ export default { ArtifactsBulkDelete, BulkDeleteModal, ArtifactsTableRowDetails, - FeedbackBanner, }, directives: { GlTooltip: GlTooltipDirective, @@ -374,7 +372,6 @@ export default { </script> <template> <div> - <feedback-banner /> <artifacts-bulk-delete v-if="canBulkDestroyArtifacts" :selected-artifacts="selectedArtifacts" diff --git a/app/assets/javascripts/ci/artifacts/constants.js b/app/assets/javascripts/ci/artifacts/constants.js index 2d89b6541f3..28c371cda1e 100644 --- a/app/assets/javascripts/ci/artifacts/constants.js +++ b/app/assets/javascripts/ci/artifacts/constants.js @@ -47,13 +47,6 @@ export const I18N_MODAL_BODY = s__( export const I18N_MODAL_PRIMARY = s__('Artifacts|Delete artifact'); export const I18N_MODAL_CANCEL = __('Cancel'); -export const I18N_FEEDBACK_BANNER_TITLE = s__('Artifacts|Help us improve this page'); -export const I18N_FEEDBACK_BANNER_BODY = s__( - 'Artifacts|We want you to be able to use this page to easily manage your CI/CD job artifacts. We are working to improve this experience and would appreciate any feedback you have about the improvements we are making.', -); -export const I18N_FEEDBACK_BANNER_BUTTON = s__('Artifacts|Take a quick survey'); -export const FEEDBACK_URL = 'https://gitlab.fra1.qualtrics.com/jfe/form/SV_cI9rAUI20Vo2St8'; - export const SELECTED_ARTIFACTS_MAX_COUNT = 50; export const I18N_BULK_DELETE_MAX_SELECTED = s__( 'Artifacts|Maximum selected artifacts limit reached', diff --git a/app/assets/javascripts/ci/artifacts/index.js b/app/assets/javascripts/ci/artifacts/index.js index 6e795fd9bd7..c6021eb056f 100644 --- a/app/assets/javascripts/ci/artifacts/index.js +++ b/app/assets/javascripts/ci/artifacts/index.js @@ -19,12 +19,7 @@ export const initArtifactsTable = () => { return false; } - const { - projectPath, - projectId, - canDestroyArtifacts, - artifactsManagementFeedbackImagePath, - } = el.dataset; + const { projectPath, projectId, canDestroyArtifacts } = el.dataset; return new Vue({ el, @@ -33,7 +28,6 @@ export const initArtifactsTable = () => { projectPath, projectId, canDestroyArtifacts: parseBoolean(canDestroyArtifacts), - artifactsManagementFeedbackImagePath, }, render: (createElement) => createElement(App), }); diff --git a/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js deleted file mode 100644 index 574a5e7fd99..00000000000 --- a/app/assets/javascripts/ci/ci_variable_list/ci_variable_list.js +++ /dev/null @@ -1,262 +0,0 @@ -import $ from 'jquery'; -import SecretValues from '~/behaviors/secret_values'; -import CreateItemDropdown from '~/create_item_dropdown'; -import { parseBoolean } from '~/lib/utils/common_utils'; -import { s__ } from '~/locale'; - -const ALL_ENVIRONMENTS_STRING = s__('CiVariable|All environments'); - -function createEnvironmentItem(value) { - return { - title: value === '*' ? ALL_ENVIRONMENTS_STRING : value, - id: value, - text: value === '*' ? s__('CiVariable|* (All environments)') : value, - }; -} - -export default class VariableList { - constructor({ container, formField, maskableRegex }) { - this.$container = $(container); - this.formField = formField; - this.maskableRegex = new RegExp(maskableRegex); - this.environmentDropdownMap = new WeakMap(); - - this.inputMap = { - id: { - selector: '.js-ci-variable-input-id', - default: '', - }, - variable_type: { - selector: '.js-ci-variable-input-variable-type', - default: 'env_var', - }, - key: { - selector: '.js-ci-variable-input-key', - default: '', - }, - secret_value: { - selector: '.js-ci-variable-input-value', - default: '', - }, - protected: { - selector: '.js-ci-variable-input-protected', - // use `attr` instead of `data` as we don't want the value to be - // converted. we need the value as a string. - default: $('.js-ci-variable-input-protected').attr('data-default'), - }, - masked: { - selector: '.js-ci-variable-input-masked', - // use `attr` instead of `data` as we don't want the value to be - // converted. we need the value as a string. - default: $('.js-ci-variable-input-masked').attr('data-default'), - }, - environment_scope: { - // We can't use a `.js-` class here because - // deprecated_jquery_dropdown replaces the <input> and doesn't copy over the class - // See https://gitlab.com/gitlab-org/gitlab-foss/issues/42458 - selector: `input[name="${this.formField}[variables_attributes][][environment_scope]"]`, - default: '*', - }, - _destroy: { - selector: '.js-ci-variable-input-destroy', - default: '', - }, - }; - - this.secretValues = new SecretValues({ - container: this.$container[0], - valueSelector: '.js-row:not(:last-child) .js-secret-value', - placeholderSelector: '.js-row:not(:last-child) .js-secret-value-placeholder', - }); - } - - init() { - this.bindEvents(); - this.secretValues.init(); - } - - bindEvents() { - this.$container.find('.js-row').each((index, rowEl) => { - this.initRow(rowEl); - }); - - this.$container.on('click', '.js-row-remove-button', (e) => { - e.preventDefault(); - this.removeRow($(e.currentTarget).closest('.js-row')); - }); - - const inputSelector = Object.keys(this.inputMap) - .map((name) => this.inputMap[name].selector) - .join(','); - - // Remove any empty rows except the last row - this.$container.on('blur', inputSelector, (e) => { - const $row = $(e.currentTarget).closest('.js-row'); - - if ($row.is(':not(:last-child)') && !this.checkIfRowTouched($row)) { - this.removeRow($row); - } - }); - - this.$container.on('input trigger-change', inputSelector, (e) => { - // Always make sure there is an empty last row - const $lastRow = this.$container.find('.js-row').last(); - - if (this.checkIfRowTouched($lastRow)) { - this.insertRow($lastRow); - } - - // If masked, validate value against regex - this.validateMaskability($(e.currentTarget).closest('.js-row')); - }); - } - - initRow(rowEl) { - const $row = $(rowEl); - - // Reset the resizable textarea - $row.find(this.inputMap.secret_value.selector).css('height', ''); - - const $environmentSelect = $row.find('.js-variable-environment-toggle'); - if ($environmentSelect.length) { - const createItemDropdown = new CreateItemDropdown({ - $dropdown: $environmentSelect, - defaultToggleLabel: ALL_ENVIRONMENTS_STRING, - fieldName: `${this.formField}[variables_attributes][][environment_scope]`, - getData: (term, callback) => callback(this.getEnvironmentValues()), - createNewItemFromValue: createEnvironmentItem, - onSelect: () => { - // Refresh the other dropdowns in the variable list - // so they have the new value we just picked - this.refreshDropdownData(); - - $row.find(this.inputMap.environment_scope.selector).trigger('trigger-change'); - }, - }); - - // Clear out any data that might have been left-over from the row clone - createItemDropdown.clearDropdown(); - - this.environmentDropdownMap.set($row[0], createItemDropdown); - } - } - - insertRow($row) { - const $rowClone = $row.clone(); - $rowClone.removeAttr('data-is-persisted'); - - // Reset the inputs to their defaults - Object.keys(this.inputMap).forEach((name) => { - const entry = this.inputMap[name]; - $rowClone.find(entry.selector).val(entry.default); - }); - - // Close any dropdowns - $rowClone.find('.dropdown-menu.show').each((index, $dropdown) => { - $dropdown.classList.remove('show'); - }); - - this.initRow($rowClone); - - $row.after($rowClone); - } - - removeRow(row) { - const $row = $(row); - const isPersisted = parseBoolean($row.attr('data-is-persisted')); - - if (isPersisted) { - $row.hide(); - $row - // eslint-disable-next-line no-underscore-dangle - .find(this.inputMap._destroy.selector) - .val(true); - } else { - $row.remove(); - } - - // Refresh the other dropdowns in the variable list - // so any value with the variable deleted is gone - this.refreshDropdownData(); - } - - checkIfRowTouched($row) { - return Object.keys(this.inputMap).some((name) => { - // Row should not qualify as touched if only switches have been touched - if (['protected', 'masked'].includes(name)) return false; - - const entry = this.inputMap[name]; - const $el = $row.find(entry.selector); - return $el.length && $el.val() !== entry.default; - }); - } - - validateMaskability($row) { - const invalidInputClass = 'gl-field-error-outline'; - - const variableValue = $row.find(this.inputMap.secret_value.selector).val(); - const isValueMaskable = this.maskableRegex.test(variableValue) || variableValue === ''; - const isMaskedChecked = $row.find(this.inputMap.masked.selector).val() === 'true'; - - // Show a validation error if the user wants to mask an unmaskable variable value - $row - .find(this.inputMap.secret_value.selector) - .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); - $row - .find('.js-secret-value-placeholder') - .toggleClass(invalidInputClass, isMaskedChecked && !isValueMaskable); - $row.find('.masking-validation-error').toggle(isMaskedChecked && !isValueMaskable); - } - - toggleEnableRow(isEnabled = true) { - this.$container.find(this.inputMap.key.selector).attr('disabled', !isEnabled); - this.$container.find('.js-row-remove-button').attr('disabled', !isEnabled); - } - - hideValues() { - this.secretValues.updateDom(false); - } - - getAllData() { - // Ignore the last empty row because we don't want to try persist - // a blank variable and run into validation problems. - const validRows = this.$container.find('.js-row').toArray().slice(0, -1); - - return validRows.map((rowEl) => { - const resultant = {}; - Object.keys(this.inputMap).forEach((name) => { - const entry = this.inputMap[name]; - const $input = $(rowEl).find(entry.selector); - if ($input.length) { - resultant[name] = $input.val(); - } - }); - - return resultant; - }); - } - - getEnvironmentValues() { - const valueMap = this.$container - .find(this.inputMap.environment_scope.selector) - .toArray() - .reduce( - (prevValueMap, envInput) => ({ - ...prevValueMap, - [envInput.value]: envInput.value, - }), - {}, - ); - - return Object.keys(valueMap).map(createEnvironmentItem); - } - - refreshDropdownData() { - this.$container.find('.js-row').each((index, rowEl) => { - const environmentDropdown = this.environmentDropdownMap.get(rowEl); - if (environmentDropdown) { - environmentDropdown.refreshData(); - } - }); - } -} diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index 0ce11da658c..c609e05bbb7 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -1,10 +1,12 @@ <script> import { + GlAlert, GlButton, GlDrawer, GlFormCheckbox, GlFormCombobox, GlFormGroup, + GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, @@ -15,9 +17,14 @@ import { __, s__ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; import { helpPagePath } from '~/helpers/help_page_helper'; +import Tracking from '~/tracking'; import { + allEnvironments, defaultVariableState, + DRAWER_EVENT_LABEL, + EDIT_VARIABLE_ACTION, ENVIRONMENT_SCOPE_LINK_TITLE, + EVENT_ACTION, EXPANDED_VARIABLES_NOTE, FLAG_LINK_TITLE, VARIABLE_ACTIONS, @@ -26,9 +33,13 @@ import { import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; import { awsTokenList } from './ci_variable_autocomplete_tokens'; -const i18n = { +const trackingMixin = Tracking.mixin({ label: DRAWER_EVENT_LABEL }); + +export const i18n = { addVariable: s__('CiVariables|Add Variable'), cancel: __('Cancel'), + defaultScope: allEnvironments.text, + editVariable: s__('CiVariables|Edit Variable'), environments: __('Environments'), environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, expandedField: s__('CiVariables|Expand variable reference'), @@ -44,39 +55,60 @@ const i18n = { protectedDescription: s__( 'CiVariables|Export variable to pipelines running on protected branches and tags only.', ), + valueFeedback: { + rawHelpText: s__('CiVariables|Variable value will be evaluated as raw string.'), + maskedReqsNotMet: s__( + 'CiVariables|This variable value does not meet the masking requirements.', + ), + }, + variableReferenceTitle: s__('CiVariables|Value might contain a variable reference'), + variableReferenceDescription: s__( + 'CiVariables|Unselect "Expand variable reference" if you want to use the variable value as a raw string.', + ), type: __('Type'), value: __('Value'), }; +const VARIABLE_REFERENCE_REGEX = /\$/; + export default { DRAWER_Z_INDEX, components: { CiEnvironmentsDropdown, + GlAlert, GlButton, GlDrawer, GlFormCheckbox, GlFormCombobox, GlFormGroup, + GlFormInput, GlFormSelect, GlFormTextarea, GlIcon, GlLink, GlSprintf, }, - inject: ['environmentScopeLink'], + mixins: [trackingMixin], + inject: ['environmentScopeLink', 'isProtectedByDefault', 'maskableRawRegex', 'maskableRegex'], props: { areEnvironmentsLoading: { type: Boolean, required: true, }, + areScopedVariablesAvailable: { + type: Boolean, + required: false, + default: false, + }, environments: { type: Array, required: false, default: () => [], }, - hasEnvScopeQuery: { + hideEnvironmentScope: { type: Boolean, - required: true, + required: false, + default: false, }, mode: { type: String, @@ -85,22 +117,107 @@ export default { return VARIABLE_ACTIONS.includes(val); }, }, + selectedVariable: { + type: Object, + required: false, + default: () => {}, + }, }, data() { return { - key: defaultVariableState.key, - variableType: defaultVariableState.variableType, + variable: { ...defaultVariableState, ...this.selectedVariable }, + trackedValidationErrorProperty: undefined, }; }, computed: { + isValueMaskable() { + return this.variable.masked && !this.isValueMasked; + }, + isValueMasked() { + const regex = RegExp(this.maskedRegexToUse); + return regex.test(this.variable.value); + }, + canSubmit() { + return this.variable.key.length > 0 && this.isValueValid; + }, getDrawerHeaderHeight() { return getContentWrapperHeight(); }, + hasVariableReference() { + return this.isExpanded && VARIABLE_REFERENCE_REGEX.test(this.variable.value); + }, + isExpanded() { + return !this.variable.raw; + }, + isMaskedReqsMet() { + return !this.variable.masked || this.isValueMasked; + }, + isValueEmpty() { + return this.variable.value === ''; + }, + isValueValid() { + return this.isValueEmpty || this.isMaskedReqsMet; + }, + isEditing() { + return this.mode === EDIT_VARIABLE_ACTION; + }, + maskedRegexToUse() { + return this.variable.raw ? this.maskableRawRegex : this.maskableRegex; + }, + maskedReqsNotMetText() { + return !this.isMaskedReqsMet ? this.$options.i18n.valueFeedback.maskedReqsNotMet : ''; + }, + modalActionText() { + return this.isEditing ? this.$options.i18n.editVariable : this.$options.i18n.addVariable; + }, + }, + watch: { + variable: { + handler() { + this.trackVariableValidationErrors(); + }, + deep: true, + }, + }, + mounted() { + if (this.isProtectedByDefault && !this.isEditing) { + this.variable = { ...this.variable, protected: true }; + } }, methods: { close() { this.$emit('close-form'); }, + getTrackingErrorProperty() { + if (this.isValueEmpty) { + return null; + } + + let property; + if (this.isValueMaskable) { + const supportedChars = this.maskedRegexToUse.replace('^', '').replace(/{(\d,)}\$/, ''); + const regex = new RegExp(supportedChars, 'g'); + property = this.variable.value.replace(regex, ''); + } else if (this.hasVariableReference) { + property = '$'; + } + + return property; + }, + setRaw(expanded) { + this.variable = { ...this.variable, raw: !expanded }; + }, + submit() { + this.$emit(this.isEditing ? 'update-variable' : 'add-variable', this.variable); + this.close(); + }, + trackVariableValidationErrors() { + const property = this.getTrackingErrorProperty(); + if (property && !this.trackedValidationErrorProperty) { + this.track(EVENT_ACTION, { property }); + this.trackedValidationErrorProperty = property; + } + }, }, awsTokenList, flagLink: helpPagePath('ci/variables/index', { @@ -119,20 +236,25 @@ export default { @close="close" > <template #title> - <h2 class="gl-m-0">{{ $options.i18n.addVariable }}</h2> + <h2 class="gl-m-0">{{ modalActionText }}</h2> </template> <gl-form-group :label="$options.i18n.type" label-for="ci-variable-type" - class="gl-border-none gl-mb-n5" + class="gl-border-none" + :class="{ + 'gl-mb-n5': !hideEnvironmentScope, + 'gl-mb-n1': hideEnvironmentScope, + }" > <gl-form-select id="ci-variable-type" - v-model="variableType" + v-model="variable.variableType" :options="$options.variableOptions" /> </gl-form-group> <gl-form-group + v-if="!hideEnvironmentScope" class="gl-border-none gl-mb-n5" label-for="ci-variable-env" data-testid="environment-scope" @@ -154,11 +276,18 @@ export default { </div> </template> <ci-environments-dropdown + v-if="areScopedVariablesAvailable" class="gl-mb-5" + has-env-scope-query :are-environments-loading="areEnvironmentsLoading" :environments="environments" - :has-env-scope-query="hasEnvScopeQuery" - selected-environment-scope="" + :selected-environment-scope="variable.environmentScope" + /> + <gl-form-input + v-else + :value="$options.i18n.defaultScope" + class="gl-w-full gl-mb-5" + readonly /> </gl-form-group> <gl-form-group class="gl-border-none gl-mb-n8"> @@ -177,17 +306,21 @@ export default { </gl-link> </div> </template> - <gl-form-checkbox data-testid="ci-variable-protected-checkbox"> + <gl-form-checkbox v-model="variable.protected" data-testid="ci-variable-protected-checkbox"> {{ $options.i18n.protectedField }} <p class="gl-text-secondary"> {{ $options.i18n.protectedDescription }} </p> </gl-form-checkbox> - <gl-form-checkbox data-testid="ci-variable-masked-checkbox"> + <gl-form-checkbox v-model="variable.masked" data-testid="ci-variable-masked-checkbox"> {{ $options.i18n.maskedField }} <p class="gl-text-secondary">{{ $options.i18n.maskedDescription }}</p> </gl-form-checkbox> - <gl-form-checkbox data-testid="ci-variable-expanded-checkbox"> + <gl-form-checkbox + data-testid="ci-variable-expanded-checkbox" + :checked="isExpanded" + @change="setRaw" + > {{ $options.i18n.expandedField }} <p class="gl-text-secondary"> <gl-sprintf :message="$options.i18n.expandedDescription" class="gl-text-secondary"> @@ -199,34 +332,56 @@ export default { </gl-form-checkbox> </gl-form-group> <gl-form-combobox - v-model="key" + v-model="variable.key" :token-list="$options.awsTokenList" :label-text="$options.i18n.key" class="gl-border-none gl-pb-0! gl-mb-n5" - data-testid="pipeline-form-ci-variable-key" + data-testid="ci-variable-key" data-qa-selector="ci_variable_key_field" /> <gl-form-group :label="$options.i18n.value" label-for="ci-variable-value" class="gl-border-none gl-mb-n2" + data-testid="ci-variable-value-label" + :invalid-feedback="maskedReqsNotMetText" + :state="isValueValid" > <gl-form-textarea id="ci-variable-value" + v-model="variable.value" class="gl-border-none gl-font-monospace!" rows="3" max-rows="10" - data-testid="pipeline-form-ci-variable-value" + data-testid="ci-variable-value" data-qa-selector="ci_variable_value_field" spellcheck="false" /> + <p v-if="variable.raw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> + {{ $options.i18n.valueFeedback.rawHelpText }} + </p> </gl-form-group> + <gl-alert + v-if="hasVariableReference" + :title="$options.i18n.variableReferenceTitle" + :dismissible="false" + variant="warning" + class="gl-mx-4 gl-pl-9! gl-border-bottom-0" + data-testid="has-variable-reference-alert" + > + {{ $options.i18n.variableReferenceDescription }} + </gl-alert> <div class="gl-display-flex gl-justify-content-end"> - <gl-button category="primary" class="gl-mr-3" data-testid="cancel-button" @click="close" + <gl-button category="secondary" class="gl-mr-3" data-testid="cancel-button" @click="close" >{{ $options.i18n.cancel }} </gl-button> - <gl-button category="primary" variant="confirm" data-testid="confirm-button" - >{{ $options.i18n.addVariable }} + <gl-button + category="primary" + variant="confirm" + :disabled="!canSubmit" + data-testid="ci-variable-confirm-btn" + @click="submit" + >{{ modalActionText }} </gl-button> </div> </gl-drawer> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index f4e1da9b34f..482f6da5617 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -139,7 +139,10 @@ export default { <ci-variable-drawer v-if="showDrawer" :are-environments-loading="areEnvironmentsLoading" - :has-env-scope-query="hasEnvScopeQuery" + :are-scoped-variables-available="areScopedVariablesAvailable" + :environments="environments" + :hide-environment-scope="hideEnvironmentScope" + :selected-variable="selectedVariable" :mode="mode" v-on="$listeners" @close-form="closeForm" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue index 9786f25ed87..3d5ed327dc7 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_shared.vue @@ -2,7 +2,8 @@ import { createAlert } from '~/alert'; import { __ } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { mapEnvironmentNames, reportMessageToSentry } from '../utils'; +import { reportMessageToSentry } from '~/ci/utils'; +import { mapEnvironmentNames } from '../utils'; import { ADD_MUTATION_ACTION, DELETE_MUTATION_ACTION, diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index a14cd1e387a..3d62313815c 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue @@ -32,24 +32,20 @@ export default { label: s__('CiVariables|Key'), tdClass: 'text-plain', sortable: true, + thClass: 'gl-w-40p', }, { key: 'value', label: s__('CiVariables|Value'), }, { - key: 'Attributes', - label: s__('CiVariables|Attributes'), - thClass: 'gl-w-40p', - }, - { key: 'environmentScope', label: s__('CiVariables|Environments'), }, { key: 'actions', label: __('Actions'), - thClass: 'gl-text-right', + thClass: 'gl-text-right gl-w-15', }, ], inheritedVarsFields: [ @@ -287,7 +283,7 @@ export default { </template> <template #cell(key)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span :id="`ci-variable-key-${item.id}`" @@ -298,16 +294,28 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy key')" :data-clipboard-text="item.key" :aria-label="__('Copy to clipboard')" /> </div> + <div data-testid="ci-variable-table-row-attributes" class="gl-mt-2"> + <gl-badge + v-for="attribute in item.attributes" + :key="`${item.key}-${attribute}`" + class="gl-mr-2" + variant="info" + size="sm" + > + {{ attribute }} + </gl-badge> + </div> </template> <template v-if="!isInheritedGroupVars" #cell(value)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span v-if="areValuesHidden" data-testid="hiddenValue">*****</span> <span @@ -321,29 +329,17 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy value')" :data-clipboard-text="item.value" :aria-label="__('Copy to clipboard')" /> </div> </template> - <template #cell(attributes)="{ item }"> - <span data-testid="ci-variable-table-row-attributes"> - <gl-badge - v-for="attribute in item.attributes" - :key="`${item.key}-${attribute}`" - class="gl-mr-2" - variant="info" - size="sm" - > - {{ attribute }} - </gl-badge> - </span> - </template> <template #cell(environmentScope)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <span :id="`ci-variable-env-${item.id}`" @@ -354,7 +350,8 @@ export default { v-gl-tooltip category="tertiary" icon="copy-to-clipboard" - class="gl-my-n3 gl-ml-2" + class="gl-my-n2 gl-ml-2" + size="small" :title="__('Copy environment')" :data-clipboard-text="convertEnvironmentScopeValue(item.environmentScope)" :aria-label="__('Copy to clipboard')" @@ -363,7 +360,7 @@ export default { </template> <template v-if="isInheritedGroupVars" #cell(group)="{ item }"> <div - class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-lg-justify-content-start gl-mr-n3" + class="gl-display-flex gl-align-items-flex-start gl-justify-content-end gl-md-justify-content-start gl-mr-n3" > <gl-link :id="`ci-variable-group-${item.id}`" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index 825b39e0cf9..fc37b62299d 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -46,6 +46,7 @@ export const AWS_TIP_MESSAGE = s__( ); export const EVENT_LABEL = 'ci_variable_modal'; +export const DRAWER_EVENT_LABEL = 'ci_variable_drawer'; export const EVENT_ACTION = 'validation_error'; // AWS TOKEN CONSTANTS diff --git a/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js b/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js deleted file mode 100644 index fdbefd8c313..00000000000 --- a/app/assets/javascripts/ci/ci_variable_list/native_form_variable_list.js +++ /dev/null @@ -1,25 +0,0 @@ -import $ from 'jquery'; -import VariableList from './ci_variable_list'; - -// Used for the variable list on scheduled pipeline edit page -export default function setupNativeFormVariableList({ container, formField = 'variables' }) { - const $container = $(container); - - const variableList = new VariableList({ - container: $container, - formField, - }); - variableList.init(); - - // Clear out the names in the empty last row so it - // doesn't get submitted and throw validation errors - $container.closest('form').on('submit trigger-submit', () => { - const $lastRow = $container.find('.js-row').last(); - - const isTouched = variableList.checkIfRowTouched($lastRow); - if (!isTouched) { - $lastRow.find('input, textarea').attr('name', ''); - $lastRow.find('select').attr('name', ''); - } - }); -} diff --git a/app/assets/javascripts/ci/ci_variable_list/utils.js b/app/assets/javascripts/ci/ci_variable_list/utils.js index eeca69274ce..1faa97a5f73 100644 --- a/app/assets/javascripts/ci/ci_variable_list/utils.js +++ b/app/assets/javascripts/ci/ci_variable_list/utils.js @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/browser'; import { uniq } from 'lodash'; import { allEnvironments } from './constants'; @@ -49,12 +48,3 @@ export const convertEnvironmentScope = (environmentScope = '') => { export const mapEnvironmentNames = (nodes = []) => { return nodes.map((env) => env.name); }; - -export const reportMessageToSentry = (component, message, context) => { - Sentry.withScope((scope) => { - // eslint-disable-next-line @gitlab/require-i18n-strings - scope.setContext('Vue data', context); - scope.setTag('component', component); - Sentry.captureMessage(message); - }); -}; diff --git a/app/assets/javascripts/ci/common/pipelines_table.vue b/app/assets/javascripts/ci/common/pipelines_table.vue new file mode 100644 index 00000000000..807128d2341 --- /dev/null +++ b/app/assets/javascripts/ci/common/pipelines_table.vue @@ -0,0 +1,240 @@ +<script> +import { GlTableLite, GlTooltipDirective } from '@gitlab/ui'; +import { cleanLeadingSeparator } from '~/lib/utils/url_utility'; +import { s__, __ } from '~/locale'; +import Tracking from '~/tracking'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import PipelineFailedJobsWidget from '~/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue'; +import eventHub from '~/ci/event_hub'; +import PipelineOperations from '../pipelines_page/components/pipeline_operations.vue'; +import PipelineStopModal from '../pipelines_page/components/pipeline_stop_modal.vue'; +import PipelineTriggerer from '../pipelines_page/components/pipeline_triggerer.vue'; +import PipelineUrl from '../pipelines_page/components/pipeline_url.vue'; +import PipelinesStatusBadge from '../pipelines_page/components/pipelines_status_badge.vue'; + +const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; + +export default { + components: { + GlTableLite, + LegacyPipelineMiniGraph, + PipelineFailedJobsWidget, + PipelineOperations, + PipelinesStatusBadge, + PipelineStopModal, + PipelineTriggerer, + PipelineUrl, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin(), glFeatureFlagMixin()], + inject: { + withFailedJobsDetails: { + default: false, + }, + }, + props: { + pipelines: { + type: Array, + required: true, + }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, + updateGraphDropdown: { + type: Boolean, + required: false, + default: false, + }, + viewType: { + type: String, + required: true, + }, + pipelineKeyOption: { + type: Object, + required: true, + }, + }, + data() { + return { + pipelineId: 0, + pipeline: {}, + endpoint: '', + cancelingPipeline: null, + }; + }, + computed: { + showFailedJobsWidget() { + return this.glFeatures.ciJobFailuresInMr; + }, + tableFields() { + return [ + { + key: 'status', + label: s__('Pipeline|Status'), + thClass: DEFAULT_TH_CLASSES, + columnClass: 'gl-w-15p', + tdClass: this.tdClasses, + thAttr: { 'data-testid': 'status-th' }, + }, + { + key: 'pipeline', + label: __('Pipeline'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${this.tdClasses}`, + columnClass: 'gl-w-30p', + thAttr: { 'data-testid': 'pipeline-th' }, + }, + { + key: 'triggerer', + label: s__('Pipeline|Created by'), + thClass: DEFAULT_TH_CLASSES, + tdClass: `${this.tdClasses} ${HIDE_TD_ON_MOBILE}`, + columnClass: 'gl-w-15p', + thAttr: { 'data-testid': 'triggerer-th' }, + }, + { + key: 'stages', + label: s__('Pipeline|Stages'), + thClass: DEFAULT_TH_CLASSES, + tdClass: this.tdClasses, + columnClass: 'gl-w-quarter', + thAttr: { 'data-testid': 'stages-th' }, + }, + { + key: 'actions', + thClass: DEFAULT_TH_CLASSES, + tdClass: this.tdClasses, + columnClass: 'gl-w-20p', + thAttr: { 'data-testid': 'actions-th' }, + }, + ]; + }, + tdClasses() { + return this.withFailedJobsDetails ? 'gl-pb-0! gl-border-none!' : 'pl-p-5!'; + }, + pipelinesWithDetails() { + if (this.withFailedJobsDetails) { + return this.pipelines.map((p) => { + return { ...p, _showDetails: true }; + }); + } + + return this.pipelines; + }, + }, + watch: { + pipelines() { + this.cancelingPipeline = null; + }, + }, + created() { + eventHub.$on('openConfirmationModal', this.setModalData); + }, + beforeDestroy() { + eventHub.$off('openConfirmationModal', this.setModalData); + }, + methods: { + getDownstreamPipelines(pipeline) { + const downstream = pipeline.triggered; + return keepLatestDownstreamPipelines(downstream); + }, + getProjectPath(item) { + return cleanLeadingSeparator(item.project.full_path); + }, + failedJobsCount(pipeline) { + return pipeline?.failed_builds?.length || 0; + }, + setModalData(data) { + this.pipelineId = data.pipeline.id; + this.pipeline = data.pipeline; + this.endpoint = data.endpoint; + }, + onSubmit() { + eventHub.$emit('postAction', this.endpoint); + this.cancelingPipeline = this.pipelineId; + }, + trackPipelineMiniGraph() { + this.track('click_minigraph', { label: TRACKING_CATEGORIES.table }); + }, + }, + TBODY_TR_ATTR: { + 'data-testid': 'pipeline-table-row', + 'data-qa-selector': 'pipeline_row_container', + }, +}; +</script> +<template> + <div class="ci-table"> + <gl-table-lite + :fields="tableFields" + :items="pipelinesWithDetails" + :tbody-tr-attr="$options.TBODY_TR_ATTR" + stacked="lg" + fixed + > + <template #head(actions)> + <span class="gl-display-block gl-lg-display-none!">{{ s__('Pipeline|Actions') }}</span> + <slot name="table-header-actions"></slot> + </template> + + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(status)="{ item }"> + <pipelines-status-badge :pipeline="item" :view-type="viewType" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-url + :pipeline="item" + :pipeline-schedule-url="pipelineScheduleUrl" + :pipeline-key="pipelineKeyOption.value" + ref-color="gl-text-black-normal" + /> + </template> + + <template #cell(triggerer)="{ item }"> + <pipeline-triggerer :pipeline="item" /> + </template> + + <template #cell(stages)="{ item }"> + <legacy-pipeline-mini-graph + :downstream-pipelines="getDownstreamPipelines(item)" + :pipeline-path="item.path" + :stages="item.details.stages" + :update-dropdown="updateGraphDropdown" + :upstream-pipeline="item.triggered_by" + @miniGraphStageClick="trackPipelineMiniGraph" + /> + </template> + + <template #cell(actions)="{ item }"> + <pipeline-operations :pipeline="item" :canceling-pipeline="cancelingPipeline" /> + </template> + + <template #row-details="{ item }"> + <pipeline-failed-jobs-widget + v-if="showFailedJobsWidget" + :failed-jobs-count="failedJobsCount(item)" + :is-pipeline-active="item.active" + :pipeline-iid="item.iid" + :pipeline-path="item.path" + :project-path="getProjectPath(item)" + class="gl-ml-n4 gl-mt-n3 gl-mb-n1" + /> + </template> + </gl-table-lite> + + <pipeline-stop-modal :pipeline="pipeline" @submit="onSubmit" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/common/private/job_action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue new file mode 100644 index 00000000000..f649750ce8a --- /dev/null +++ b/app/assets/javascripts/ci/common/private/job_action_component.vue @@ -0,0 +1,136 @@ +<script> +import { GlTooltipDirective, GlButton, GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { dasherize } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; +import { reportToSentry } from '~/ci/utils'; + +/** + * Renders either a cancel, retry or play icon button and handles the post request + * + * Used in: + * - mr widget mini pipeline graph: `mr_widget_pipeline.vue` + * - pipelines table + * - pipelines table in merge request page + * - pipelines table in commit page + * - pipelines detail page in big graph + */ +export default { + components: { + GlIcon, + GlButton, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + tooltipText: { + type: String, + required: true, + }, + link: { + type: String, + required: true, + }, + actionIcon: { + type: String, + required: true, + }, + withConfirmationModal: { + type: Boolean, + required: false, + default: false, + }, + shouldTriggerClick: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isDisabled: false, + isLoading: false, + }; + }, + computed: { + cssClass() { + const actionIconDash = dasherize(this.actionIcon); + return `${actionIconDash} js-icon-${actionIconDash}`; + }, + }, + watch: { + shouldTriggerClick(flag) { + if (flag && this.withConfirmationModal) { + this.executeAction(); + this.$emit('actionButtonClicked'); + } + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('action_component', `error: ${err}, info: ${info}`); + }, + methods: { + /** + * The request should not be handled here. + * However due to this component being used in several + * different apps it avoids repetition & complexity. + * + */ + onClickAction() { + if (this.withConfirmationModal) { + this.$emit('showActionConfirmationModal'); + } else { + this.executeAction(); + } + }, + executeAction() { + this.$root.$emit(BV_HIDE_TOOLTIP, `js-ci-action-${this.link}`); + this.isDisabled = true; + this.isLoading = true; + + axios + .post(`${this.link}.json`) + .then(() => { + this.isLoading = false; + + this.$emit('pipelineActionRequestComplete'); + }) + .catch((err) => { + this.isDisabled = false; + this.isLoading = false; + + reportToSentry('action_component', err); + + createAlert({ + message: __('An error occurred while making the request.'), + }); + }); + }, + }, +}; +</script> +<template> + <gl-button + :id="`js-ci-action-${link}`" + ref="button" + :class="cssClass" + :disabled="isDisabled" + class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" + data-testid="ci-action-component" + @click.stop="onClickAction" + > + <div + v-gl-tooltip.viewport + :title="tooltipText" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full" + data-testid="ci-action-icon-tooltip-wrapper" + > + <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" /> + <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> + </div> + </gl-button> +</template> diff --git a/app/assets/javascripts/ci/common/private/job_links_layer.vue b/app/assets/javascripts/ci/common/private/job_links_layer.vue new file mode 100644 index 00000000000..59260ca3f81 --- /dev/null +++ b/app/assets/javascripts/ci/common/private/job_links_layer.vue @@ -0,0 +1,75 @@ +<script> +import { memoize } from 'lodash'; +import { reportToSentry } from '~/ci/utils'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; + +const parseForLinksBare = (pipeline) => { + const arrayOfJobs = pipeline.flatMap(({ groups }) => groups); + return parseData(arrayOfJobs).links; +}; + +const parseForLinks = memoize(parseForLinksBare); + +export default { + name: 'LinksLayer', + components: { + LinksInner, + }, + props: { + containerMeasurements: { + type: Object, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + linksData: { + type: Array, + required: false, + default: () => [], + }, + showLinks: { + type: Boolean, + required: false, + default: true, + }, + }, + computed: { + containerZero() { + return !this.containerMeasurements.width || !this.containerMeasurements.height; + }, + getLinksData() { + if (this.linksData.length > 0) { + return this.linksData; + } + + return parseForLinks(this.pipelineData); + }, + showLinkedLayers() { + return this.showLinks && !this.containerZero; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, +}; +</script> +<template> + <links-inner + v-if="showLinkedLayers" + :container-measurements="containerMeasurements" + :links-data="getLinksData" + :pipeline-data="pipelineData" + v-bind="$attrs" + v-on="$listeners" + > + <slot></slot> + </links-inner> + <div v-else> + <div class="gl-display-flex gl-relative"> + <slot></slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/common/private/job_name_component.vue b/app/assets/javascripts/ci/common/private/job_name_component.vue new file mode 100644 index 00000000000..1c7f5a7476d --- /dev/null +++ b/app/assets/javascripts/ci/common/private/job_name_component.vue @@ -0,0 +1,38 @@ +<script> +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +/** + * Component that renders both the CI icon status and the job name. + * Used in + * - Badge component + * - Dropdown badge components + */ +export default { + components: { + CiIcon, + }, + props: { + name: { + type: String, + required: true, + }, + status: { + type: Object, + required: true, + }, + iconSize: { + type: Number, + required: false, + default: 16, + }, + }, +}; +</script> +<template> + <span class="mw-100 gl-display-flex gl-align-items-center gl-flex-grow-1"> + <ci-icon :size="iconSize" :status="status" class="gl-line-height-0" /> + <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> + {{ name }} + </span> + </span> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue new file mode 100644 index 00000000000..86ccdb2c87b --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/app.vue @@ -0,0 +1,99 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { + OPERATOR_IS, + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, + TOKEN_TITLE_JOBS_RUNNER_TYPE, + TOKEN_TYPE_JOBS_RUNNER_TYPE, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import JobStatusToken from './tokens/job_status_token.vue'; +import JobRunnerTypeToken from './tokens/job_runner_type_token.vue'; + +export default { + components: { + GlFilteredSearch, + }, + mixins: [glFeatureFlagsMixin()], + props: { + queryString: { + type: Object, + required: false, + default: null, + }, + }, + computed: { + tokens() { + const tokens = [ + { + type: TOKEN_TYPE_STATUS, + icon: 'status', + title: TOKEN_TITLE_STATUS, + unique: true, + token: JobStatusToken, + operators: OPERATORS_IS, + }, + ]; + + if (this.glFeatures.adminJobsFilterRunnerType) { + tokens.push({ + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + title: TOKEN_TITLE_JOBS_RUNNER_TYPE, + unique: true, + token: JobRunnerTypeToken, + operators: OPERATORS_IS, + }); + } + + return tokens; + }, + filteredSearchValue() { + return Object.entries(this.queryString || {}).reduce( + (acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': + return [ + ...acc, + { + type: TOKEN_TYPE_STATUS, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + case 'runnerTypes': + if (!this.glFeatures.adminJobsFilterRunnerType) { + return acc; + } + + return [ + ...acc, + { + type: TOKEN_TYPE_JOBS_RUNNER_TYPE, + value: { data: queryStringValue, operator: OPERATOR_IS }, + }, + ]; + default: + return acc; + } + }, + [], + ); + }, + }, + methods: { + onSubmit(filters) { + this.$emit('filterJobsBySearch', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + :placeholder="s__('Jobs|Filter jobs')" + :available-tokens="tokens" + :value="filteredSearchValue" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js new file mode 100644 index 00000000000..86b8290864c --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/constants.js @@ -0,0 +1,23 @@ +export const jobStatusValues = [ + 'CANCELED', + 'CREATED', + 'FAILED', + 'MANUAL', + 'SUCCESS', + 'PENDING', + 'PREPARING', + 'RUNNING', + 'SCHEDULED', + 'SKIPPED', + 'WAITING_FOR_RESOURCE', +]; + +export const JOB_RUNNER_TYPE_INSTANCE_TYPE = 'INSTANCE_TYPE'; +export const JOB_RUNNER_TYPE_GROUP_TYPE = 'GROUP_TYPE'; +export const JOB_RUNNER_TYPE_PROJECT_TYPE = 'PROJECT_TYPE'; + +export const jobRunnerTypeValues = [ + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +]; diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue new file mode 100644 index 00000000000..5bd3693b4d9 --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_runner_type_token.vue @@ -0,0 +1,79 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + JOB_RUNNER_TYPE_INSTANCE_TYPE, + JOB_RUNNER_TYPE_GROUP_TYPE, + JOB_RUNNER_TYPE_PROJECT_TYPE, +} from '../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + runnerTypes() { + return [ + { + class: 'ci-runner-runner-type-instance', + icon: 'users', + text: s__('Runners|Instance'), + value: JOB_RUNNER_TYPE_INSTANCE_TYPE, + }, + { + class: 'ci-runner-runner-type-group', + icon: 'group', + text: s__('Runners|Group'), + value: JOB_RUNNER_TYPE_GROUP_TYPE, + }, + { + class: 'ci-runner-runner-type-project', + icon: 'project', + text: s__('Runners|Project'), + value: JOB_RUNNER_TYPE_PROJECT_TYPE, + }, + ]; + }, + findActiveRunnerType() { + return this.runnerTypes.find((runnerType) => runnerType.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveRunnerType.class"> + <gl-icon :name="findActiveRunnerType.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveRunnerType.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(runnerType, index) in runnerTypes" + :key="index" + :value="runnerType.value" + > + <div class="gl-display-flex" :class="runnerType.class"> + <gl-icon :name="runnerType.icon" class="gl-mr-3" /> + <span>{{ runnerType.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue new file mode 100644 index 00000000000..aad86ded80a --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/tokens/job_status_token.vue @@ -0,0 +1,122 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Job|Canceled'), + value: 'CANCELED', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Job|Created'), + value: 'CREATED', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Job|Failed'), + value: 'FAILED', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Job|Manual'), + value: 'MANUAL', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Job|Passed'), + value: 'SUCCESS', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Job|Pending'), + value: 'PENDING', + }, + { + class: 'ci-status-icon-preparing', + icon: 'status_preparing', + text: s__('Job|Preparing'), + value: 'PREPARING', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Job|Running'), + value: 'RUNNING', + }, + { + class: 'ci-status-icon-scheduled', + icon: 'status_scheduled', + text: s__('Job|Scheduled'), + value: 'SCHEDULED', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Job|Skipped'), + value: 'SKIPPED', + }, + { + class: 'ci-status-icon-waiting-for-resource', + icon: 'status-waiting', + text: s__('Job|Waiting for resource'), + value: 'WAITING_FOR_RESOURCE', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find((status) => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js new file mode 100644 index 00000000000..43c0da72d3d --- /dev/null +++ b/app/assets/javascripts/ci/common/private/jobs_filtered_search/utils.js @@ -0,0 +1,22 @@ +import { jobStatusValues, jobRunnerTypeValues } from './constants'; + +// validates query string used for filtered search +// on jobs table to ensure GraphQL query is called correctly +export const validateQueryString = (queryStringObj) => { + return Object.entries(queryStringObj).reduce((acc, [queryStringKey, queryStringValue]) => { + switch (queryStringKey) { + case 'statuses': { + const statusValue = queryStringValue.toUpperCase(); + const statusValueValid = jobStatusValues.includes(statusValue); + return statusValueValid ? { ...acc, statuses: statusValue } : acc; + } + case 'runnerTypes': { + const runnerTypesValue = queryStringValue.toUpperCase(); + const runnerTypesValueValid = jobRunnerTypeValues.includes(runnerTypesValue); + return runnerTypesValueValid ? { ...acc, runnerTypes: runnerTypesValue } : acc; + } + default: + return acc; + } + }, null); +}; diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js new file mode 100644 index 00000000000..93c2504dd5d --- /dev/null +++ b/app/assets/javascripts/ci/constants.js @@ -0,0 +1,51 @@ +import { __, s__ } from '~/locale'; + +export const forwardDeploymentFailureModalId = 'forward-deployment-failure'; + +export const BUTTON_TOOLTIP_RETRY = __('Retry all failed or cancelled jobs'); +export const BUTTON_TOOLTIP_CANCEL = __('Cancel the running pipeline'); + +export const FILTER_TAG_IDENTIFIER = 'tag'; + +export const JOB_GRAPHQL_ERRORS = { + jobMutationErrorText: __('There was an error running the job. Please try again.'), + jobQueryErrorText: __('There was an error fetching the job.'), +}; + +export const ICONS = { + TAG: 'tag', + MR: 'git-merge', + BRANCH: 'branch', + RETRY: 'retry', + SUCCESS: 'success', +}; + +export const SUCCESS_STATUS = 'SUCCESS'; +export const PASSED_STATUS = 'passed'; +export const MANUAL_STATUS = 'manual'; + +// Constants for the ID and IID selection dropdown +export const PipelineKeyOptions = [ + { + text: __('Show Pipeline ID'), + label: __('Pipeline ID'), + value: 'id', + }, + { + text: __('Show Pipeline IID'), + label: __('Pipeline IID'), + value: 'iid', + }, +]; + +export const RAW_TEXT_WARNING = s__( + 'Pipeline|Raw text search is not currently supported. Please use the available search tokens.', +); + +export const TRACKING_CATEGORIES = { + table: 'pipelines_table_component', + tabs: 'pipelines_filter_tabs', + search: 'pipelines_filtered_search', + failed: 'pipeline_failed_jobs_tab', + tests: 'pipeline_tests_tab', +}; diff --git a/app/assets/javascripts/ci/event_hub.js b/app/assets/javascripts/ci/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/ci/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue index 27ee1b794f6..f02d59af1d9 100644 --- a/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue +++ b/app/assets/javascripts/ci/inherited_ci_variables/components/inherited_ci_variables_app.vue @@ -3,7 +3,7 @@ import { produce } from 'immer'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import { reportMessageToSentry } from '~/ci/ci_variable_list/utils'; +import { reportMessageToSentry } from '~/ci/utils'; import CiVariableTable from '~/ci/ci_variable_list/components/ci_variable_table.vue'; import getInheritedCiVariables from '../graphql/queries/inherited_ci_variables.query.graphql'; diff --git a/app/assets/javascripts/ci/job_details/components/empty_state.vue b/app/assets/javascripts/ci/job_details/components/empty_state.vue new file mode 100644 index 00000000000..5756d4a71df --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/empty_state.vue @@ -0,0 +1,100 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue'; + +export default { + components: { + GlLink, + ManualVariablesForm, + }, + props: { + illustrationPath: { + type: String, + required: true, + }, + illustrationSizeClass: { + type: String, + required: true, + }, + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + title: { + type: String, + required: true, + }, + content: { + type: String, + required: false, + default: null, + }, + playable: { + type: Boolean, + required: true, + default: false, + }, + scheduled: { + type: Boolean, + required: false, + default: false, + }, + action: { + type: Object, + required: false, + default: null, + validator(value) { + return ( + value === null || + (Object.prototype.hasOwnProperty.call(value, 'path') && + Object.prototype.hasOwnProperty.call(value, 'method') && + Object.prototype.hasOwnProperty.call(value, 'button_title')) + ); + }, + }, + }, + computed: { + shouldRenderManualVariables() { + return this.playable && !this.scheduled; + }, + }, +}; +</script> +<template> + <div class="row empty-state"> + <div class="col-12"> + <div :class="illustrationSizeClass" class="svg-content"> + <img :src="illustrationPath" /> + </div> + </div> + + <div class="col-12"> + <div class="text-content"> + <h4 class="text-center" data-testid="job-empty-state-title">{{ title }}</h4> + + <p v-if="content" data-testid="job-empty-state-content">{{ content }}</p> + </div> + <manual-variables-form + v-if="shouldRenderManualVariables" + :is-retryable="isRetryable" + :job-id="jobId" + @hideManualVariablesForm="$emit('hideManualVariablesForm')" + /> + <div v-if="action && !shouldRenderManualVariables" class="text-content"> + <div class="text-center"> + <gl-link + :href="action.path" + :data-method="action.method" + class="btn gl-button btn-confirm gl-text-decoration-none!" + data-testid="job-empty-state-action" + >{{ action.button_title }}</gl-link + > + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/environments_block.vue b/app/assets/javascripts/ci/job_details/components/environments_block.vue new file mode 100644 index 00000000000..4046e1ade82 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/environments_block.vue @@ -0,0 +1,214 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { __ } from '~/locale'; + +export default { + creatingEnvironment: 'creating', + components: { + CiIcon, + GlSprintf, + GlLink, + }, + props: { + deploymentStatus: { + type: Object, + required: true, + }, + deploymentCluster: { + type: Object, + required: false, + default: null, + }, + iconStatus: { + type: Object, + required: true, + }, + }, + computed: { + environment() { + switch (this.deploymentStatus.status) { + case 'last': + return this.lastEnvironmentMessage(); + case 'out_of_date': + return this.outOfDateEnvironmentMessage(); + case 'failed': + return this.failedEnvironmentMessage(); + case this.$options.creatingEnvironment: + return this.creatingEnvironmentMessage(); + default: + return ''; + } + }, + environmentLink() { + if (this.hasEnvironment) { + return { + link: this.deploymentStatus.environment.environment_path, + name: this.deploymentStatus.environment.name, + }; + } + return {}; + }, + hasLastDeployment() { + return this.hasEnvironment && this.deploymentStatus.environment.last_deployment; + }, + lastDeployment() { + return this.hasLastDeployment ? this.deploymentStatus.environment.last_deployment : {}; + }, + hasEnvironment() { + return !isEmpty(this.deploymentStatus.environment); + }, + lastDeploymentPath() { + return !isEmpty(this.lastDeployment.deployable) + ? this.lastDeployment.deployable.build_path + : ''; + }, + hasCluster() { + return Boolean(this.deploymentCluster) && Boolean(this.deploymentCluster.name); + }, + clusterNameOrLink() { + if (!this.hasCluster) { + return ''; + } + + const { name, path } = this.deploymentCluster; + + return { + path, + name, + }; + }, + kubernetesNamespace() { + return this.hasCluster ? this.deploymentCluster.kubernetes_namespace : null; + }, + deploymentLink() { + return { + path: this.lastDeploymentPath, + name: + this.deploymentStatus.status === this.$options.creatingEnvironment + ? __('latest deployment') + : __('most recent deployment'), + }; + }, + }, + methods: { + failedEnvironmentMessage() { + return __('The deployment of this job to %{environmentLink} did not succeed.'); + }, + lastEnvironmentMessage() { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __('This job is deployed to %{environmentLink} using cluster %{clusterNameOrLink}.'); + } + // not a cluster deployment + return __('This job is deployed to %{environmentLink}.'); + }, + outOfDateEnvironmentMessage() { + if (this.hasLastDeployment) { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. View the %{deploymentLink}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}. View the %{deploymentLink}.', + ); + } + // not a cluster deployment + return __( + 'This job is an out-of-date deployment to %{environmentLink}. View the %{deploymentLink}.', + ); + } + // no last deployment, i.e. this is the first deployment + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is an out-of-date deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ); + } + // not a cluster deployment + return __('This job is an out-of-date deployment to %{environmentLink}.'); + }, + creatingEnvironmentMessage() { + if (this.hasLastDeployment) { + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}. This will overwrite the %{deploymentLink}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}. This will overwrite the %{deploymentLink}.', + ); + } + // not a cluster deployment + return __( + 'This job is creating a deployment to %{environmentLink}. This will overwrite the %{deploymentLink}.', + ); + } + // no last deployment, i.e. this is the first deployment + if (this.hasCluster) { + if (this.kubernetesNamespace) { + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink} and namespace %{kubernetesNamespace}.', + ); + } + // we know the cluster but not the namespace + return __( + 'This job is creating a deployment to %{environmentLink} using cluster %{clusterNameOrLink}.', + ); + } + // not a cluster deployment + return __('This job is creating a deployment to %{environmentLink}.'); + }, + }, +}; +</script> +<template> + <div class="gl-mt-3 gl-mb-3 js-environment-container"> + <div class="environment-information"> + <ci-icon :status="iconStatus" /> + <p class="inline gl-mb-0"> + <gl-sprintf :message="environment"> + <template #environmentLink> + <gl-link + v-if="hasEnvironment" + :href="environmentLink.link" + data-testid="job-environment-link" + >{{ environmentLink.name }}</gl-link + > + </template> + <template #clusterNameOrLink> + <gl-link + v-if="clusterNameOrLink.path" + :href="clusterNameOrLink.path" + data-testid="job-cluster-link" + >{{ clusterNameOrLink.name }}</gl-link + > + <template v-else>{{ clusterNameOrLink.name }}</template> + </template> + <template #kubernetesNamespace>{{ kubernetesNamespace }}</template> + <template #deploymentLink> + <gl-link :href="deploymentLink.path" data-testid="job-deployment-link">{{ + deploymentLink.name + }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/erased_block.vue b/app/assets/javascripts/ci/job_details/components/erased_block.vue new file mode 100644 index 00000000000..a815689659e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/erased_block.vue @@ -0,0 +1,49 @@ +<script> +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + GlAlert, + GlLink, + GlSprintf, + TimeagoTooltip, + }, + props: { + user: { + type: Object, + required: false, + default: () => ({}), + }, + erasedAt: { + type: String, + required: true, + }, + }, + computed: { + isErasedByUser() { + return !isEmpty(this.user); + }, + }, +}; +</script> +<template> + <div class="gl-mt-3"> + <gl-alert variant="warning" :dismissible="false"> + <template v-if="isErasedByUser"> + <gl-sprintf :message="s__('Job|Job has been erased by %{userLink}')"> + <template #userLink> + <gl-link :href="user.web_url" target="_blank">{{ user.username }}</gl-link> + </template> + </gl-sprintf> + </template> + + <template v-else> + {{ s__('Job|Job has been erased') }} + </template> + + <timeago-tooltip :time="erasedAt" /> + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/job_header.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue new file mode 100644 index 00000000000..13f3eebd447 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -0,0 +1,148 @@ +<script> +import { GlTooltipDirective, GlButton, GlAvatarLink, GlAvatarLabeled, GlTooltip } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { glEmojiTag } from '~/emoji'; +import { __, sprintf } from '~/locale'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + +export default { + components: { + CiBadgeLink, + TimeagoTooltip, + GlButton, + GlAvatarLink, + GlAvatarLabeled, + GlTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + EMOJI_REF: 'EMOJI_REF', + props: { + status: { + type: Object, + required: true, + }, + name: { + type: String, + required: true, + }, + time: { + type: String, + required: true, + }, + user: { + type: Object, + required: true, + }, + shouldRenderTriggeredLabel: { + type: Boolean, + required: true, + }, + }, + + computed: { + userAvatarAltText() { + return sprintf(__(`%{username}'s avatar`), { username: this.user.name }); + }, + userPath() { + // GraphQL returns `webPath` and Rest `path` + return this.user?.webPath || this.user?.path; + }, + avatarUrl() { + // GraphQL returns `avatarUrl` and Rest `avatar_url` + return this.user?.avatarUrl || this.user?.avatar_url; + }, + webUrl() { + // GraphQL returns `webUrl` and Rest `web_url` + return this.user?.webUrl || this.user?.web_url; + }, + statusTooltipHTML() { + // Rest `status_tooltip_html` which is a ready to work + // html for the emoji and the status text inside a tooltip. + // GraphQL returns `status.emoji` and `status.message` which + // needs to be combined to make the html we want. + const { emoji } = this.user?.status || {}; + const emojiHtml = emoji ? glEmojiTag(emoji) : ''; + + return emojiHtml || this.user?.status_tooltip_html; + }, + message() { + return this.user?.status?.message; + }, + userId() { + return isGid(this.user?.id) ? getIdFromGraphQLId(this.user?.id) : this.user?.id; + }, + }, + + methods: { + onClickSidebarButton() { + this.$emit('clickedSidebarButton'); + }, + }, + safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, +}; +</script> + +<template> + <header + class="page-content-header gl-md-display-flex gl-min-h-7" + data-testid="job-header-content" + > + <section class="header-main-content gl-mr-3"> + <ci-badge-link class="gl-mr-3" :status="status" /> + + <strong data-testid="job-name">{{ name }}</strong> + + <template v-if="shouldRenderTriggeredLabel">{{ __('started') }}</template> + <template v-else>{{ __('created') }}</template> + + <timeago-tooltip :time="time" /> + + {{ __('by') }} + + <template v-if="user"> + <gl-avatar-link + :data-user-id="userId" + :data-username="user.username" + :data-name="user.name" + :href="webUrl" + target="_blank" + class="js-user-link gl-vertical-align-middle gl-mx-2 gl-align-items-center" + > + <gl-avatar-labeled + :size="24" + :src="avatarUrl" + :label="user.name" + class="gl-display-none gl-sm-display-inline-flex gl-mx-1" + /> + <strong class="author gl-display-inline gl-sm-display-none!">@{{ user.username }}</strong> + <gl-tooltip v-if="message" :target="() => $refs[$options.EMOJI_REF]"> + {{ message }} + </gl-tooltip> + <span + v-if="statusTooltipHTML" + :ref="$options.EMOJI_REF" + v-safe-html:[$options.safeHtmlConfig]="statusTooltipHTML" + class="gl-ml-2" + :data-testid="message" + ></span> + </gl-avatar-link> + </template> + </section> + + <!-- eslint-disable-next-line @gitlab/vue-prefer-dollar-scopedslots --> + <section v-if="$slots.default" data-testid="job-header-action-buttons" class="gl-display-flex"> + <slot></slot> + </section> + <gl-button + class="gl-md-display-none gl-ml-auto gl-align-self-start js-sidebar-build-toggle" + icon="chevron-double-lg-left" + :aria-label="__('Toggle sidebar')" + @click="onClickSidebarButton" + /> + </header> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue new file mode 100644 index 00000000000..419efcba46d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/job_log_controllers.vue @@ -0,0 +1,260 @@ +<script> +import { GlTooltipDirective, GlLink, GlButton, GlSearchBoxByClick } from '@gitlab/ui'; +import { scrollToElement, backOff } from '~/lib/utils/common_utils'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { __, s__, sprintf } from '~/locale'; +import { compactJobLog } from '~/ci/job_details/utils'; +import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + i18n: { + scrollToBottomButtonLabel: s__('Job|Scroll to bottom'), + scrollToTopButtonLabel: s__('Job|Scroll to top'), + scrollToNextFailureButtonLabel: s__('Job|Scroll to next failure'), + showRawButtonLabel: s__('Job|Show complete raw'), + searchPlaceholder: s__('Job|Search job log'), + noResults: s__('Job|No search results found'), + searchPopoverTitle: s__('Job|Job log search'), + searchPopoverDescription: s__( + 'Job|Search for substrings in your job log output. Currently search is only supported for the visible job log output, not for any log output that is truncated due to size.', + ), + logLineNumberNotFound: s__('Job|We could not find this element'), + }, + components: { + GlLink, + GlButton, + GlSearchBoxByClick, + HelpPopover, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + size: { + type: Number, + required: true, + }, + rawPath: { + type: String, + required: false, + default: null, + }, + isScrollTopDisabled: { + type: Boolean, + required: true, + }, + isScrollBottomDisabled: { + type: Boolean, + required: true, + }, + isScrollingDown: { + type: Boolean, + required: true, + }, + isJobLogSizeVisible: { + type: Boolean, + required: true, + }, + isComplete: { + type: Boolean, + required: true, + }, + jobLog: { + type: Array, + required: true, + }, + }, + data() { + return { + searchTerm: '', + searchResults: [], + failureCount: null, + failureIndex: 0, + }; + }, + computed: { + jobLogSize() { + return sprintf(__('Showing last %{size} of log -'), { + size: numberToHumanSize(this.size), + }); + }, + showJumpToFailures() { + return this.glFeatures.jobLogJumpToFailures; + }, + hasFailures() { + return this.failureCount > 0; + }, + shouldDisableJumpToFailures() { + return !this.hasFailures; + }, + }, + mounted() { + this.checkFailureCount(); + }, + methods: { + checkFailureCount() { + if (this.glFeatures.jobLogJumpToFailures) { + backOff((next, stop) => { + this.failureCount = document.querySelectorAll('.term-fg-l-red').length; + + if (this.hasFailures || (this.isComplete && !this.hasFailures)) { + stop(); + } else { + next(); + } + }).catch(() => { + this.failureCount = null; + }); + } + }, + handleScrollToNextFailure() { + const failures = document.querySelectorAll('.term-fg-l-red'); + const nextFailure = failures[this.failureIndex]; + + if (nextFailure) { + nextFailure.scrollIntoView({ block: 'center' }); + this.failureIndex = (this.failureIndex + 1) % failures.length; + } + }, + handleScrollToTop() { + this.$emit('scrollJobLogTop'); + this.failureIndex = 0; + }, + handleScrollToBottom() { + this.$emit('scrollJobLogBottom'); + this.failureIndex = 0; + }, + searchJobLog() { + this.searchResults = []; + + if (!this.searchTerm) return; + + const compactedLog = compactJobLog(this.jobLog); + + compactedLog.forEach((line) => { + const lineText = line.content[0].text; + + if (lineText.toLocaleLowerCase().includes(this.searchTerm.toLocaleLowerCase())) { + this.searchResults.push(line); + } + }); + + if (this.searchResults.length > 0) { + this.$emit('searchResults', this.searchResults); + + // BE returns zero based index, we need to add one to match the line numbers in the DOM + const firstSearchResult = `#L${this.searchResults[0].lineNumber + 1}`; + const logLine = document.querySelector(`.log-line ${firstSearchResult}`); + + if (logLine) { + setTimeout(() => scrollToElement(logLine)); + + const message = sprintf(s__('Job|%{searchLength} results found for %{searchTerm}'), { + searchLength: this.searchResults.length, + searchTerm: this.searchTerm, + }); + + this.$toast.show(message); + } else { + this.$toast.show(this.$options.i18n.logLineNumberNotFound); + } + } else { + this.$toast.show(this.$options.i18n.noResults); + } + }, + }, +}; +</script> +<template> + <div class="top-bar gl-display-flex gl-justify-content-space-between"> + <slot name="drawers"></slot> + <!-- truncate information --> + <div + class="truncated-info gl-display-none gl-sm-display-flex gl-flex-wrap gl-align-items-center" + data-testid="log-truncated-info" + > + <template v-if="isJobLogSizeVisible"> + {{ jobLogSize }} + <gl-link + v-if="rawPath" + :href="rawPath" + class="text-plain text-underline gl-ml-2" + data-testid="raw-link" + >{{ s__('Job|Complete Raw') }}</gl-link + > + </template> + </div> + <!-- eo truncate information --> + + <div class="controllers"> + <slot name="controllers"> </slot> + <gl-search-box-by-click + v-model="searchTerm" + class="gl-mr-3" + :placeholder="$options.i18n.searchPlaceholder" + data-testid="job-log-search-box" + @clear="$emit('searchResults', [])" + @submit="searchJobLog" + /> + + <help-popover class="gl-mr-3"> + <template #title>{{ $options.i18n.searchPopoverTitle }}</template> + + <p class="gl-mb-0"> + {{ $options.i18n.searchPopoverDescription }} + </p> + </help-popover> + + <!-- links --> + <gl-button + v-if="rawPath" + v-gl-tooltip.body + :title="$options.i18n.showRawButtonLabel" + :aria-label="$options.i18n.showRawButtonLabel" + :href="rawPath" + data-testid="job-raw-link-controller" + icon="doc-code" + /> + <!-- eo links --> + + <!-- scroll buttons --> + <gl-button + v-if="showJumpToFailures" + v-gl-tooltip + :title="$options.i18n.scrollToNextFailureButtonLabel" + :aria-label="$options.i18n.scrollToNextFailureButtonLabel" + :disabled="shouldDisableJumpToFailures" + class="btn-scroll gl-ml-3" + data-testid="job-controller-scroll-to-failure" + icon="soft-wrap" + @click="handleScrollToNextFailure" + /> + + <div v-gl-tooltip :title="$options.i18n.scrollToTopButtonLabel" class="gl-ml-3"> + <gl-button + :disabled="isScrollTopDisabled" + class="btn-scroll" + data-testid="job-controller-scroll-top" + icon="scroll_up" + :aria-label="$options.i18n.scrollToTopButtonLabel" + @click="handleScrollToTop" + /> + </div> + + <div v-gl-tooltip :title="$options.i18n.scrollToBottomButtonLabel" class="gl-ml-3"> + <gl-button + :disabled="isScrollBottomDisabled" + class="js-scroll-bottom btn-scroll" + data-testid="job-controller-scroll-bottom" + icon="scroll_down" + :class="{ animate: isScrollingDown }" + :aria-label="$options.i18n.scrollToBottomButtonLabel" + @click="handleScrollToBottom" + /> + </div> + <!-- eo scroll buttons --> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue new file mode 100644 index 00000000000..39c612bc600 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/collapsible_section.vue @@ -0,0 +1,71 @@ +<script> +import LogLine from './line.vue'; +import LogLineHeader from './line_header.vue'; + +export default { + name: 'CollapsibleLogSection', + components: { + LogLine, + LogLineHeader, + }, + props: { + section: { + type: Object, + required: true, + }, + jobLogEndpoint: { + type: String, + required: true, + }, + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + badgeDuration() { + return this.section.line && this.section.line.section_duration; + }, + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, + headerIsHighlighted() { + const { + line: { lineNumber }, + } = this.section; + + return this.highlightedLines.includes(lineNumber); + }, + }, + methods: { + handleOnClickCollapsibleLine(section) { + this.$emit('onClickCollapsibleLine', section); + }, + lineIsHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, + }, +}; +</script> +<template> + <div> + <log-line-header + :line="section.line" + :duration="badgeDuration" + :path="jobLogEndpoint" + :is-closed="section.isClosed" + :is-highlighted="headerIsHighlighted" + @toggleLine="handleOnClickCollapsibleLine(section)" + /> + <template v-if="!section.isClosed"> + <log-line + v-for="line in section.lines" + :key="line.offset" + :line="line" + :path="jobLogEndpoint" + :is-highlighted="lineIsHighlighted(line)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue new file mode 100644 index 00000000000..54b76fd9edd --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/duration_badge.vue @@ -0,0 +1,20 @@ +<script> +import { GlBadge } from '@gitlab/ui'; + +export default { + components: { + GlBadge, + }, + props: { + duration: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-badge> + {{ duration }} + </gl-badge> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line.vue b/app/assets/javascripts/ci/job_details/components/log/line.vue new file mode 100644 index 00000000000..fa4a12b3dd3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line.vue @@ -0,0 +1,83 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { getLocationHash } from '~/lib/utils/url_utility'; +import { linkRegex } from './utils'; +import LineNumber from './line_number.vue'; + +export default { + functional: true, + props: { + line: { + type: Object, + required: true, + }, + path: { + type: String, + required: true, + }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + }, + render(h, { props }) { + const { line, path, isHighlighted } = props; + + const chars = line.content.map((content) => { + return h( + 'span', + { + class: ['gl-white-space-pre-wrap', content.style], + }, + // Simple "tokenization": Split text in chunks of text + // which alternate between text and urls. + content.text.split(linkRegex).map((chunk) => { + // Return normal string for non-links + if (!chunk.match(linkRegex)) { + return chunk; + } + return h( + 'a', + { + attrs: { + href: chunk, + class: 'gl-reset-color! gl-text-decoration-underline', + rel: 'nofollow noopener noreferrer', // eslint-disable-line @gitlab/require-i18n-strings + }, + }, + chunk, + ); + }), + ); + }); + + let applyHashHighlight = false; + + if (window.location.hash) { + const hash = getLocationHash(); + const lineToMatch = `L${line.lineNumber + 1}`; + + if (hash === lineToMatch) { + applyHashHighlight = true; + } + } + + return h( + 'div', + { + class: ['js-line', 'log-line', { 'gl-bg-gray-700': isHighlighted || applyHashHighlight }], + }, + [ + h(LineNumber, { + props: { + lineNumber: line.lineNumber, + path, + }, + }), + ...chars, + ], + ); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue new file mode 100644 index 00000000000..e647ab4ac0b --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -0,0 +1,81 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import DurationBadge from './duration_badge.vue'; +import LineNumber from './line_number.vue'; + +export default { + components: { + GlIcon, + LineNumber, + DurationBadge, + }, + props: { + line: { + type: Object, + required: true, + }, + isClosed: { + type: Boolean, + required: true, + }, + path: { + type: String, + required: true, + }, + duration: { + type: String, + required: false, + default: '', + }, + isHighlighted: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + applyHashHighlight: false, + }; + }, + computed: { + iconName() { + return this.isClosed ? 'chevron-lg-right' : 'chevron-lg-down'; + }, + }, + mounted() { + const hash = getLocationHash(); + const lineToMatch = `L${this.line.lineNumber + 1}`; + + if (hash === lineToMatch) { + this.applyHashHighlight = true; + } + }, + methods: { + handleOnClick() { + this.$emit('toggleLine'); + }, + }, +}; +</script> + +<template> + <div + class="log-line collapsible-line d-flex justify-content-between ws-normal gl-align-items-flex-start gl-relative" + :class="{ 'gl-bg-gray-700': isHighlighted || applyHashHighlight }" + role="button" + @click="handleOnClick" + > + <gl-icon :name="iconName" class="arrow gl-absolute gl-top-2" /> + <line-number :line-number="line.lineNumber" :path="path" /> + <span + v-for="(content, i) in line.content" + :key="i" + class="line-text w-100 gl-white-space-pre-wrap" + :class="content.style" + >{{ content.text }}</span + > + <duration-badge v-if="duration" :duration="duration" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_number.vue b/app/assets/javascripts/ci/job_details/components/log/line_number.vue new file mode 100644 index 00000000000..7ca9154d2fe --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/line_number.vue @@ -0,0 +1,34 @@ +<script> +export default { + functional: true, + props: { + lineNumber: { + type: Number, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + render(h, { props }) { + const { lineNumber, path } = props; + + const parsedLineNumber = lineNumber + 1; + const lineId = `L${parsedLineNumber}`; + const lineHref = `${path}#${lineId}`; + + return h( + 'a', + { + class: 'gl-link d-inline-block text-right line-number flex-shrink-0', + attrs: { + id: lineId, + href: lineHref, + }, + }, + parsedLineNumber, + ); + }, +}; +</script> diff --git a/app/assets/javascripts/ci/job_details/components/log/log.vue b/app/assets/javascripts/ci/job_details/components/log/log.vue new file mode 100644 index 00000000000..fb6a6a58074 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/log.vue @@ -0,0 +1,106 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState, mapActions } from 'vuex'; +import { scrollToElement } from '~/lib/utils/common_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; +import CollapsibleLogSection from './collapsible_section.vue'; +import LogLine from './line.vue'; + +export default { + components: { + CollapsibleLogSection, + LogLine, + }, + props: { + searchResults: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + ...mapState([ + 'jobLogEndpoint', + 'jobLog', + 'isJobLogComplete', + 'isScrolledToBottomBeforeReceivingJobLog', + ]), + highlightedLines() { + return this.searchResults.map((result) => result.lineNumber); + }, + }, + updated() { + this.$nextTick(() => { + if (!window.location.hash) { + this.handleScrollDown(); + } + }); + }, + mounted() { + if (window.location.hash) { + const lineNumber = getLocationHash(); + + this.unwatchJobLog = this.$watch('jobLog', async () => { + if (this.jobLog.length) { + await this.$nextTick(); + + const el = document.getElementById(lineNumber); + scrollToElement(el); + this.unwatchJobLog(); + } + }); + } + }, + methods: { + ...mapActions(['toggleCollapsibleLine', 'scrollBottom']), + handleOnClickCollapsibleLine(section) { + this.toggleCollapsibleLine(section); + }, + /** + * The job log is sent in HTML, which means we need to use `v-html` to render it + * Using the updated hook with $nextTick is not enough to wait for the DOM to be updated + * in this case because it runs before `v-html` has finished running, since there's no + * Vue binding. + * In order to scroll the page down after `v-html` has finished, we need to use setTimeout + */ + handleScrollDown() { + if (this.isScrolledToBottomBeforeReceivingJobLog) { + setTimeout(() => { + this.scrollBottom(); + }, 0); + } + }, + isHighlighted({ lineNumber }) { + return this.highlightedLines.includes(lineNumber); + }, + }, +}; +</script> +<template> + <code class="job-log d-block" data-testid="job-log-content"> + <template v-for="(section, index) in jobLog"> + <collapsible-log-section + v-if="section.isHeader" + :key="`collapsible-${index}`" + :section="section" + :job-log-endpoint="jobLogEndpoint" + :search-results="searchResults" + @onClickCollapsibleLine="handleOnClickCollapsibleLine" + /> + <log-line + v-else + :key="section.offset" + :line="section" + :path="jobLogEndpoint" + :is-highlighted="isHighlighted(section)" + /> + </template> + + <div v-if="!isJobLogComplete" class="js-log-animation loader-animation pt-3 pl-3"> + <div class="dot"></div> + <div class="dot"></div> + <div class="dot"></div> + </div> + </code> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/utils.js b/app/assets/javascripts/ci/job_details/components/log/utils.js new file mode 100644 index 00000000000..1ccecf3eb53 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/log/utils.js @@ -0,0 +1,12 @@ +/** + * capture anything starting with http:// or https:// + * https?:\/\/ + * + * up until a disallowed character or whitespace + * [^"<>()\\^`{|}\s]+ + * + * and a disallowed character or whitespace, including non-ending chars .,:;!? + * [^"<>()\\^`{|}\s.,:;!?] + */ +export const linkRegex = /(https?:\/\/[^"<>()\\^`{|}\s]+[^"<>()\\^`{|}\s.,:;!?])/g; +export default { linkRegex }; diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue new file mode 100644 index 00000000000..1232ffffb57 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue @@ -0,0 +1,305 @@ +<script> +import { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlLoadingIcon, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { cloneDeep, uniqueId } from 'lodash'; +import { fetchPolicies } from '~/lib/graphql'; +import { createAlert } from '~/alert'; +import { TYPENAME_CI_BUILD, TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { JOB_GRAPHQL_ERRORS } from '~/ci/constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { s__ } from '~/locale'; +import { reportMessageToSentry } from '~/ci/utils'; +import GetJob from '../graphql/queries/get_job.query.graphql'; +import playJobWithVariablesMutation from '../graphql/mutations/job_play_with_variables.mutation.graphql'; +import retryJobWithVariablesMutation from '../graphql/mutations/job_retry_with_variables.mutation.graphql'; + +// This component is a port of ~/ci/job_details/components/legacy_manual_variables_form.vue +// It is meant to fetch/update the job information via GraphQL instead of REST API. + +export default { + name: 'ManualVariablesForm', + components: { + GlFormInputGroup, + GlInputGroupText, + GlFormInput, + GlButton, + GlLink, + GlLoadingIcon, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['projectPath'], + apollo: { + variables: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), + }; + }, + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + update(data) { + const jobVariables = cloneDeep(data?.project?.job?.manualVariables?.nodes); + return [...jobVariables.reverse(), ...this.variables]; + }, + error(error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + }, + }, + }, + props: { + isRetryable: { + type: Boolean, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, + clearBtnSharedClasses: ['gl-flex-grow-0 gl-flex-basis-0 gl-m-0! gl-ml-3!'], + inputTypes: { + key: 'key', + value: 'value', + }, + i18n: { + cancel: s__('CiVariables|Cancel'), + removeInputs: s__('CiVariables|Remove inputs'), + formHelpText: s__( + 'CiVariables|Specify variable values to be used in this run. The variables specified in the configuration file and %{linkStart}CI/CD settings%{linkEnd} are used by default.', + ), + overrideNoteText: s__( + 'CiVariables|Variables specified here are %{boldStart}expanded%{boldEnd} and not %{boldStart}masked.%{boldEnd}', + ), + header: s__('CiVariables|Variables'), + keyLabel: s__('CiVariables|Key'), + keyPlaceholder: s__('CiVariables|Input variable key'), + runAgainButtonText: s__('CiVariables|Run job again'), + runButtonText: s__('CiVariables|Run job'), + valueLabel: s__('CiVariables|Value'), + valuePlaceholder: s__('CiVariables|Input variable value'), + }, + data() { + return { + job: {}, + variables: [ + { + id: uniqueId(), + key: '', + value: '', + }, + ], + runBtnDisabled: false, + }; + }, + computed: { + mutationVariables() { + return { + id: convertToGraphQLId(TYPENAME_CI_BUILD, this.jobId), + variables: this.preparedVariables, + }; + }, + preparedVariables() { + return this.variables + .filter((variable) => variable.key !== '') + .map(({ key, value }) => ({ key, value })); + }, + runBtnText() { + return this.isRetryable + ? this.$options.i18n.runAgainButtonText + : this.$options.i18n.runButtonText; + }, + variableSettings() { + return helpPagePath('ci/variables/index', { anchor: 'add-a-cicd-variable-to-a-project' }); + }, + }, + methods: { + async playJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: playJobWithVariablesMutation, + variables: this.mutationVariables, + }); + if (data.jobPlay?.errors?.length) { + createAlert({ message: data.jobPlay.errors[0] }); + } else { + this.navigateToJob(data.jobPlay?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + } + }, + async retryJob() { + try { + const { data } = await this.$apollo.mutate({ + mutation: retryJobWithVariablesMutation, + variables: this.mutationVariables, + }); + if (data.jobRetry?.errors?.length) { + createAlert({ message: data.jobRetry.errors[0] }); + } else { + this.navigateToJob(data.jobRetry?.job?.webPath); + } + } catch (error) { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobMutationErrorText }); + reportMessageToSentry(this.$options.name, error, {}); + } + }, + addEmptyVariable() { + const lastVar = this.variables[this.variables.length - 1]; + + if (lastVar.key === '') { + return; + } + + this.variables.push({ + id: uniqueId(), + key: '', + value: '', + }); + }, + canRemove(index) { + return index < this.variables.length - 1; + }, + deleteVariable(id) { + this.variables.splice( + this.variables.findIndex((el) => el.id === id), + 1, + ); + }, + inputRef(type, id) { + return `${this.$options.inputTypes[type]}-${id}`; + }, + navigateToJob(path) { + redirectTo(path); // eslint-disable-line import/no-deprecated + }, + runJob() { + this.runBtnDisabled = true; + + if (this.isRetryable) { + this.retryJob(); + } else { + this.playJob(); + } + }, + }, +}; +</script> +<template> + <gl-loading-icon v-if="$apollo.queries.variables.loading" class="gl-mt-9" size="lg" /> + <div v-else class="row gl-justify-content-center"> + <div class="col-10"> + <label>{{ $options.i18n.header }}</label> + + <div + v-for="(variable, index) in variables" + :key="variable.id" + class="gl-display-flex gl-align-items-center gl-mb-5" + data-testid="ci-variable-row" + > + <gl-form-input-group class="gl-mr-4 gl-flex-grow-1"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.keyLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('key', variable.id)" + v-model="variable.key" + :placeholder="$options.i18n.keyPlaceholder" + data-testid="ci-variable-key" + @change="addEmptyVariable" + /> + </gl-form-input-group> + + <gl-form-input-group class="gl-flex-grow-2"> + <template #prepend> + <gl-input-group-text> + {{ $options.i18n.valueLabel }} + </gl-input-group-text> + </template> + <gl-form-input + :ref="inputRef('value', variable.id)" + v-model="variable.value" + :placeholder="$options.i18n.valuePlaceholder" + data-testid="ci-variable-value" + /> + </gl-form-input-group> + + <gl-button + v-if="canRemove(index)" + v-gl-tooltip + :aria-label="$options.i18n.removeInputs" + :title="$options.i18n.removeInputs" + :class="$options.clearBtnSharedClasses" + category="tertiary" + icon="remove" + data-testid="delete-variable-btn" + @click="deleteVariable(variable.id)" + /> + <!-- Placeholder button to keep the layout fixed --> + <gl-button + v-else + class="gl-opacity-0 gl-pointer-events-none" + :class="$options.clearBtnSharedClasses" + data-testid="delete-variable-btn-placeholder" + category="tertiary" + icon="remove" + /> + </div> + + <div class="gl-text-center gl-mt-5"> + <gl-sprintf :message="$options.i18n.formHelpText"> + <template #link="{ content }"> + <gl-link :href="variableSettings" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </div> + <div class="gl-text-center gl-mt-3"> + <gl-sprintf :message="$options.i18n.overrideNoteText"> + <template #bold="{ content }"> + <strong> + {{ content }} + </strong> + </template> + </gl-sprintf> + </div> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-button + v-if="isRetryable" + class="gl-mt-5" + data-testid="cancel-btn" + @click="$emit('hideManualVariablesForm')" + >{{ $options.i18n.cancel }}</gl-button + > + <gl-button + class="gl-mt-5" + variant="confirm" + category="primary" + :disabled="runBtnDisabled" + data-testid="run-manual-job-btn" + @click="runJob" + > + {{ runBtnText }} + </gl-button> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue new file mode 100644 index 00000000000..4c81a9bd033 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/artifacts_block.vue @@ -0,0 +1,120 @@ +<script> +import { GlButton, GlButtonGroup, GlIcon, GlLink, GlPopover } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + i18n: { + jobArtifacts: s__('Job|Job artifacts'), + artifactsHelpText: s__( + 'Job|Job artifacts are files that are configured to be uploaded when a job finishes execution. Artifacts could be compiled files, unit tests or scanning reports, or any other files generated by a job.', + ), + expiredText: s__('Job|The artifacts were removed'), + willExpireText: s__('Job|The artifacts will be removed'), + lockedText: s__( + 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', + ), + keepText: s__('Job|Keep'), + downloadText: s__('Job|Download'), + browseText: s__('Job|Browse'), + }, + artifactsHelpPath: helpPagePath('ci/jobs/job_artifacts'), + components: { + GlButton, + GlButtonGroup, + GlIcon, + GlLink, + GlPopover, + TimeagoTooltip, + }, + mixins: [timeagoMixin], + props: { + artifact: { + type: Object, + required: true, + }, + helpUrl: { + type: String, + required: true, + }, + }, + computed: { + isExpired() { + return this.artifact?.expired && !this.isLocked; + }, + isLocked() { + return this.artifact?.locked; + }, + // Only when the key is `false` we can render this block + willExpire() { + return this.artifact?.expired === false && !this.isLocked; + }, + }, +}; +</script> +<template> + <div> + <div class="title gl-font-weight-bold"> + <span class="gl-mr-2">{{ $options.i18n.jobArtifacts }}</span> + <gl-link :href="$options.artifactsHelpPath" data-testid="artifacts-help-link"> + <gl-icon id="artifacts-help" name="question-o" /> + </gl-link> + <gl-popover + target="artifacts-help" + :title="$options.i18n.jobArtifacts" + triggers="hover focus" + > + {{ $options.i18n.artifactsHelpText }} + </gl-popover> + </div> + <p + v-if="isExpired || willExpire" + class="build-detail-row" + data-testid="artifacts-remove-timeline" + > + <span v-if="isExpired">{{ $options.i18n.expiredText }}</span> + <span v-if="willExpire" data-testid="artifacts-unlocked-message-content"> + {{ $options.i18n.willExpireText }} + </span> + <timeago-tooltip v-if="artifact.expire_at" :time="artifact.expire_at" /> + <gl-link + :href="helpUrl" + target="_blank" + rel="noopener noreferrer nofollow" + data-testid="artifact-expired-help-link" + > + <gl-icon name="question-o" /> + </gl-link> + </p> + <p v-else-if="isLocked" class="build-detail-row"> + <span data-testid="artifacts-locked-message-content"> + {{ $options.i18n.lockedText }} + </span> + </p> + <gl-button-group class="gl-display-flex gl-mt-3"> + <gl-button + v-if="artifact.keep_path" + :href="artifact.keep_path" + data-method="post" + data-testid="keep-artifacts" + >{{ $options.i18n.keepText }}</gl-button + > + <gl-button + v-if="artifact.download_path" + :href="artifact.download_path" + rel="nofollow" + data-testid="download-artifacts" + download + >{{ $options.i18n.downloadText }}</gl-button + > + <gl-button + v-if="artifact.browse_path" + :href="artifact.browse_path" + data-testid="browse-artifacts-button" + >{{ $options.i18n.browseText }}</gl-button + > + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue new file mode 100644 index 00000000000..95616a4c706 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/commit_block.vue @@ -0,0 +1,54 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +export default { + components: { + ClipboardButton, + GlLink, + }, + props: { + commit: { + type: Object, + required: true, + }, + mergeRequest: { + type: Object, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <p class="gl-display-flex gl-flex-wrap gl-align-items-baseline gl-gap-2 gl-mb-0"> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Commit') }}</span> + + <gl-link + :href="commit.commit_path" + class="gl-text-blue-500! gl-font-monospace" + data-testid="commit-sha" + > + {{ commit.short_id }} + </gl-link> + + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + class="gl-align-self-center" + /> + + <span v-if="mergeRequest"> + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="gl-text-blue-500!" data-testid="link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> + </p> + + <p class="gl-mb-0">{{ commit.title }}</p> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue new file mode 100644 index 00000000000..a87f4b8467e --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/external_links_block.vue @@ -0,0 +1,34 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + components: { + GlIcon, + GlLink, + }, + props: { + externalLinks: { + type: Array, + required: true, + }, + }, +}; +</script> +<template> + <div> + <div class="title gl-font-weight-bold">{{ s__('Job|External links') }}</div> + <ul class="gl-list-style-none gl-p-0 gl-m-0"> + <li v-for="(externalLink, index) in externalLinks" :key="index"> + <gl-link + :href="externalLink.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="gl-text-blue-600!" + > + <gl-icon name="external-link" class="flex-shrink-0" /> + {{ externalLink.label }} + </gl-link> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue new file mode 100644 index 00000000000..8e87f118fa4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue @@ -0,0 +1,77 @@ +<script> +import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import { sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +export default { + components: { + CiIcon, + GlIcon, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + isActive: { + type: Boolean, + required: true, + }, + }, + computed: { + tooltipText() { + const { name, status } = this.job; + const text = `${name} - ${status.tooltip}`; + + if (this.isDelayedJob) { + return sprintf(text, { remainingTime: this.remainingTime }); + } + + return text; + }, + jobName() { + return this.job.name ? this.job.name : this.job.id; + }, + classes() { + return { + 'retried gl-text-secondary': this.job.retried, + 'gl-font-weight-bold': this.isActive, + }; + }, + dataTestId() { + return this.isActive ? 'active-job' : null; + }, + }, +}; +</script> + +<template> + <div class="build-job gl-relative" :class="classes"> + <gl-link + v-gl-tooltip.left.viewport + :href="job.status.details_path" + :title="tooltipText" + class="gl-display-flex gl-align-items-center gl-py-3 gl-pl-7" + :data-testid="dataTestId" + > + <gl-icon + v-if="isActive" + name="arrow-right" + class="icon-arrow-right gl-absolute gl-display-block" + :size="14" + /> + + <ci-icon :status="job.status" class="gl-mr-3" :size="14" /> + + <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> + + <gl-icon v-if="job.retried" name="retry" class="gl-mr-4" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue new file mode 100644 index 00000000000..58e49c71830 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_retry_forward_deployment_modal.vue @@ -0,0 +1,72 @@ +<script> +import { GlLink, GlModal } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; + +export default { + name: 'JobRetryForwardDeploymentModal', + components: { + GlLink, + GlModal, + }, + i18n: { + cancel: __('Cancel'), + info: s__( + `Jobs|You're about to retry a job that failed because it attempted to deploy code that is older than the latest deployment. + Retrying this job could result in overwriting the environment with the older source code.`, + ), + areYouSure: s__('Jobs|Are you sure you want to proceed?'), + moreInfo: __('More information'), + primaryText: __('Retry job'), + title: s__('Jobs|Are you sure you want to retry this job?'), + }, + inject: { + retryOutdatedJobDocsUrl: { + default: '', + }, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + data() { + return { + primaryProps: { + text: this.$options.i18n.primaryText, + attributes: { + 'data-method': 'post', + 'data-testid': 'retry-button-modal', + href: this.href, + variant: 'danger', + }, + }, + cancelProps: { + text: this.$options.i18n.cancel, + attributes: { category: 'secondary', variant: 'default' }, + }, + }; + }, +}; +</script> + +<template> + <gl-modal + :action-cancel="cancelProps" + :action-primary="primaryProps" + :modal-id="modalId" + :title="$options.i18n.title" + > + <p> + {{ $options.i18n.info }} + <gl-link v-if="retryOutdatedJobDocsUrl" :href="retryOutdatedJobDocsUrl" target="_blank"> + {{ $options.i18n.moreInfo }} + </gl-link> + </p> + <p>{{ $options.i18n.areYouSure }}</p> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue new file mode 100644 index 00000000000..26676123dc3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue @@ -0,0 +1,84 @@ +<script> +import { GlButton, GlDisclosureDropdown, GlModalDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; + +export default { + name: 'JobSidebarRetryButton', + i18n: { + retryJobLabel: s__('Job|Retry'), + runAgainJobButtonLabel: s__('Job|Run again'), + updateVariables: s__('Job|Update CI/CD variables'), + }, + components: { + GlButton, + GlDisclosureDropdown, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + isManualJob: { + type: Boolean, + required: true, + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + dropdownItems() { + return [ + { + text: this.$options.i18n.runAgainJobButtonLabel, + href: this.href, + extraAttrs: { + 'data-method': 'post', + }, + }, + { + text: this.$options.i18n.updateVariables, + action: () => this.$emit('updateVariablesClicked'), + }, + ]; + }, + }, +}; +</script> +<template> + <gl-button + v-if="hasForwardDeploymentFailure" + v-gl-modal="modalId" + :aria-label="$options.i18n.retryJobLabel" + category="primary" + variant="confirm" + icon="retry" + data-testid="retry-job-button" + /> + <gl-disclosure-dropdown + v-else-if="isManualJob" + icon="retry" + category="primary" + placement="right" + positioning-strategy="fixed" + variant="confirm" + :items="dropdownItems" + /> + <gl-button + v-else + :href="href" + :aria-label="$options.i18n.retryJobLabel" + category="primary" + variant="confirm" + icon="retry" + data-method="post" + data-testid="retry-job-link" + /> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue new file mode 100644 index 00000000000..18bd2593c2a --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/jobs_container.vue @@ -0,0 +1,36 @@ +<script> +import JobContainerItem from './job_container_item.vue'; + +export default { + components: { + JobContainerItem, + }, + + props: { + jobs: { + type: Array, + required: true, + }, + jobId: { + type: Number, + required: true, + }, + }, + methods: { + isJobActive(currentJobId) { + return this.jobId === currentJobId; + }, + }, +}; +</script> +<template> + <div class="block builds-container"> + <b class="gl-display-flex gl-mb-2 gl-font-weight-semibold">{{ __('Related jobs') }}</b> + <job-container-item + v-for="job in jobs" + :key="job.id" + :job="job" + :is-active="isJobActive(job.id)" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue new file mode 100644 index 00000000000..7f2f4fc0331 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar.vue @@ -0,0 +1,127 @@ +<script> +import { isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters, mapState } from 'vuex'; +import { forwardDeploymentFailureModalId } from '~/ci/constants'; +import { filterAnnotations } from '~/ci/job_details/utils'; +import ArtifactsBlock from './artifacts_block.vue'; +import CommitBlock from './commit_block.vue'; +import ExternalLinksBlock from './external_links_block.vue'; +import JobsContainer from './jobs_container.vue'; +import JobRetryForwardDeploymentModal from './job_retry_forward_deployment_modal.vue'; +import JobSidebarDetailsContainer from './sidebar_job_details_container.vue'; +import SidebarHeader from './sidebar_header.vue'; +import StagesDropdown from './stages_dropdown.vue'; +import TriggerBlock from './trigger_block.vue'; + +export default { + name: 'JobSidebar', + forwardDeploymentFailureModalId, + components: { + ArtifactsBlock, + CommitBlock, + JobsContainer, + JobRetryForwardDeploymentModal, + JobSidebarDetailsContainer, + SidebarHeader, + StagesDropdown, + TriggerBlock, + ExternalLinksBlock, + }, + props: { + artifactHelpUrl: { + type: String, + required: false, + default: '', + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + ...mapState(['job', 'stages', 'jobs', 'selectedStage']), + hasArtifact() { + // the artifact object will always have a locked property + return Object.keys(this.job.artifact).length > 1; + }, + hasExternalLinks() { + return this.externalLinks.length > 0; + }, + hasTriggers() { + return !isEmpty(this.job.trigger); + }, + commit() { + return this.job?.pipeline?.commit || {}; + }, + selectedStageData() { + return this.stages.find((val) => val.name === this.selectedStage); + }, + shouldShowJobRetryForwardDeploymentModal() { + return this.job.retry_path && this.hasForwardDeploymentFailure; + }, + externalLinks() { + return filterAnnotations(this.job.annotations, 'external_link'); + }, + }, + watch: { + job(value, oldValue) { + const hasNewStatus = value.status.text !== oldValue.status.text; + const isCurrentStage = value?.stage === this.selectedStage; + + if (hasNewStatus && isCurrentStage) { + this.fetchJobsForStage(this.selectedStageData); + } + }, + }, + methods: { + ...mapActions(['fetchJobsForStage']), + }, +}; +</script> +<template> + <aside class="right-sidebar build-sidebar" data-offset-top="101" data-spy="affix"> + <div class="sidebar-container"> + <div class="blocks-container gl-p-4"> + <sidebar-header + class="block gl-pb-4! gl-mb-2" + :rest-job="job" + :job-id="job.id" + @updateVariables="$emit('updateVariables')" + /> + + <job-sidebar-details-container class="block gl-mb-2" /> + + <artifacts-block + v-if="hasArtifact" + class="block gl-mb-2" + :artifact="job.artifact" + :help-url="artifactHelpUrl" + /> + + <external-links-block + v-if="hasExternalLinks" + class="block gl-mb-2" + :external-links="externalLinks" + /> + + <trigger-block v-if="hasTriggers" class="block gl-mb-2" :trigger="job.trigger" /> + + <commit-block class="block gl-mb-2" :commit="commit" :merge-request="job.merge_request" /> + + <stages-dropdown + v-if="job.pipeline" + class="block gl-mb-2" + :pipeline="job.pipeline" + :selected-stage="selectedStage" + :stages="stages" + @requestSidebarStageDropdown="fetchJobsForStage" + /> + + <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> + </div> + </div> + <job-retry-forward-deployment-modal + v-if="shouldShowJobRetryForwardDeploymentModal" + :modal-id="$options.forwardDeploymentFailureModalId" + :href="job.retry_path" + /> + </aside> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue new file mode 100644 index 00000000000..5b1bf354fd4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_detail_row.vue @@ -0,0 +1,64 @@ +<script> +import { GlIcon, GlLink } from '@gitlab/ui'; + +export default { + name: 'SidebarDetailRow', + components: { + GlIcon, + GlLink, + }, + props: { + title: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + helpUrl: { + type: String, + required: false, + default: '', + }, + path: { + type: String, + required: false, + default: '', + }, + }, + computed: { + hasTitle() { + return this.title.length > 0; + }, + hasHelpURL() { + return this.helpUrl.length > 0; + }, + }, +}; +</script> +<template> + <p class="build-sidebar-item gl-mb-2"> + <b v-if="hasTitle" class="gl-display-flex">{{ title }}:</b> + <gl-link + v-if="path" + :href="path" + class="gl-text-blue-600!" + data-testid="job-sidebar-value-link" + > + {{ value }} + </gl-link> + <span v-else + >{{ value }} + <gl-link + v-if="hasHelpURL" + :href="helpUrl" + target="_blank" + data-testid="job-sidebar-help-link" + > + <gl-icon name="question-o" class="gl-ml-2 gl-text-blue-500" /> + </gl-link> + </span> + </p> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..77e3ecb9b3c --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_header.vue @@ -0,0 +1,168 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapActions } from 'vuex'; +import { createAlert } from '~/alert'; +import { TYPENAME_COMMIT_STATUS } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { __, s__ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_GRAPHQL_ERRORS, forwardDeploymentFailureModalId, PASSED_STATUS } from '~/ci/constants'; +import GetJob from '../../graphql/queries/get_job.query.graphql'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +export default { + name: 'SidebarHeader', + i18n: { + cancelJobButtonLabel: s__('Job|Cancel'), + debug: __('Debug'), + eraseLogButtonLabel: s__('Job|Erase job log and artifacts'), + eraseLogConfirmText: s__('Job|Are you sure you want to erase this job log and artifacts?'), + newIssue: __('New issue'), + retryJobLabel: s__('Job|Retry'), + toggleSidebar: __('Toggle Sidebar'), + runAgainJobButtonLabel: s__('Job|Run again'), + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + inject: ['projectPath'], + apollo: { + job: { + query: GetJob, + variables() { + return { + fullPath: this.projectPath, + id: convertToGraphQLId(TYPENAME_COMMIT_STATUS, this.jobId), + }; + }, + update(data) { + const { name, manualJob } = data?.project?.job || {}; + return { + name, + manualJob, + }; + }, + error() { + createAlert({ message: JOB_GRAPHQL_ERRORS.jobQueryErrorText }); + }, + }, + }, + props: { + jobId: { + type: Number, + required: true, + }, + restJob: { + type: Object, + required: true, + default: () => ({}), + }, + }, + data() { + return { + job: {}, + }; + }, + computed: { + buttonTitle() { + return this.restJob.status?.text === PASSED_STATUS + ? this.$options.i18n.runAgainJobButtonLabel + : this.$options.i18n.retryJobLabel; + }, + canShowJobRetryButton() { + return this.restJob.retry_path && !this.$apollo.queries.job.loading; + }, + isManualJob() { + return this.job?.manualJob; + }, + retryButtonCategory() { + return this.restJob.status && this.restJob.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-mt-0 gl-mb-3 gl-text-truncate" data-testid="job-name">{{ job.name }}</h4> + </tooltip-on-truncate> + <div class="gl-display-flex gl-gap-3"> + <gl-button + v-if="restJob.erase_path" + v-gl-tooltip.bottom + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="restJob.erase_path" + :data-confirm="$options.i18n.eraseLogConfirmText" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <gl-button + v-if="restJob.new_issue_path" + v-gl-tooltip.bottom + :href="restJob.new_issue_path" + :title="$options.i18n.newIssue" + :aria-label="$options.i18n.newIssue" + category="secondary" + variant="confirm" + data-testid="job-new-issue" + icon="issue-new" + /> + <gl-button + v-if="restJob.terminal_path" + v-gl-tooltip.bottom + :href="restJob.terminal_path" + :title="$options.i18n.debug" + :aria-label="$options.i18n.debug" + target="_blank" + icon="external-link" + data-testid="terminal-link" + /> + <job-sidebar-retry-button + v-if="canShowJobRetryButton" + v-gl-tooltip.bottom + :title="buttonTitle" + :aria-label="buttonTitle" + :is-manual-job="isManualJob" + :category="retryButtonCategory" + :href="restJob.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-testid="retry-button" + @updateVariablesClicked="$emit('updateVariables')" + /> + <gl-button + v-if="restJob.cancel_path" + v-gl-tooltip.bottom + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="restJob.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="secondary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue new file mode 100644 index 00000000000..ebef3ecaa3f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/sidebar_job_details_container.vue @@ -0,0 +1,130 @@ +<script> +// eslint-disable-next-line no-restricted-imports +import { mapState } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { __, sprintf } from '~/locale'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import DetailRow from './sidebar_detail_row.vue'; + +export default { + name: 'JobSidebarDetailsContainer', + components: { + DetailRow, + GlBadge, + }, + mixins: [timeagoMixin], + computed: { + ...mapState(['job']), + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + durationTitle() { + return this.job.finished_at ? __('Duration') : __('Elapsed time'); + }, + erasedAt() { + return this.timeFormatted(this.job.erased_at); + }, + finishedAt() { + return this.timeFormatted(this.job.finished_at); + }, + hasTags() { + return this.job?.tags?.length; + }, + hasTimeout() { + return this.job?.metadata?.timeout_human_readable ?? false; + }, + hasAnyDetail() { + return Boolean( + this.job.duration || + this.job.finished_at || + this.job.erased_at || + this.job.queued_duration || + this.job.id || + this.job.runner || + this.job.coverage, + ); + }, + jobId() { + return this.job?.id ? `#${this.job.id}` : ''; + }, + runnerId() { + const { id, short_sha: token, description } = this.job.runner; + + return `#${id} (${token}) ${description}`; + }, + queuedDuration() { + return timeIntervalInWords(this.job.queued_duration); + }, + shouldRenderBlock() { + return Boolean(this.hasAnyDetail || this.hasTimeout || this.hasTags); + }, + timeout() { + return `${this.job?.metadata?.timeout_human_readable}${this.timeoutSource}`; + }, + timeoutSource() { + if (!this.job?.metadata?.timeout_source) { + return ''; + } + + return sprintf(__(' (from %{timeoutSource})'), { + timeoutSource: this.job.metadata.timeout_source, + }); + }, + runnerAdminPath() { + return this.job?.runner?.admin_path || ''; + }, + }, + i18n: { + COVERAGE: __('Coverage'), + FINISHED: __('Finished'), + ERASED: __('Erased'), + QUEUED: __('Queued'), + RUNNER: __('Runner'), + TAGS: __('Tags'), + TIMEOUT: __('Timeout'), + ID: __('Job ID'), + }, + TIMEOUT_HELP_URL: helpPagePath('/ci/pipelines/settings.md', { + anchor: 'set-a-limit-for-how-long-jobs-can-run', + }), +}; +</script> + +<template> + <div v-if="shouldRenderBlock"> + <detail-row v-if="job.duration" :value="duration" :title="durationTitle" /> + <detail-row + v-if="job.finished_at" + :value="finishedAt" + data-testid="job-finished" + :title="$options.i18n.FINISHED" + /> + <detail-row v-if="job.erased_at" :value="erasedAt" :title="$options.i18n.ERASED" /> + <detail-row v-if="job.queued_duration" :value="queuedDuration" :title="$options.i18n.QUEUED" /> + <detail-row + v-if="hasTimeout" + :help-url="$options.TIMEOUT_HELP_URL" + :value="timeout" + data-testid="job-timeout" + :title="$options.i18n.TIMEOUT" + /> + <detail-row v-if="job.id" :value="jobId" :title="$options.i18n.ID" /> + <detail-row + v-if="job.runner" + :value="runnerId" + :title="$options.i18n.RUNNER" + :path="runnerAdminPath" + /> + <detail-row v-if="job.coverage" :value="coverage" :title="$options.i18n.COVERAGE" /> + + <p v-if="hasTags" class="build-detail-row" data-testid="job-tags"> + <span class="font-weight-bold">{{ $options.i18n.TAGS }}:</span> + <gl-badge v-for="(tag, i) in job.tags" :key="i" variant="info" size="sm">{{ tag }}</gl-badge> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue new file mode 100644 index 00000000000..7744395734f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -0,0 +1,179 @@ +<script> +import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import { Mousetrap } from '~/lib/mousetrap'; +import { s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard'; +import { keysFor, MR_COPY_SOURCE_BRANCH_NAME } from '~/behaviors/shortcuts/keybindings'; + +export default { + components: { + ClipboardButton, + GlDisclosureDropdown, + GlLink, + GlSprintf, + CiBadgeLink, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: String, + required: true, + }, + }, + computed: { + dropdownItems() { + return this.stages.map((stage) => ({ + text: stage.name, + action: () => { + this.onStageClick(stage); + }, + })); + }, + + hasRef() { + return !isEmpty(this.pipeline.ref); + }, + isTriggeredByMergeRequest() { + return Boolean(this.pipeline.merge_request); + }, + isMergeRequestPipeline() { + return Boolean(this.pipeline.flags && this.pipeline.flags.merge_request_pipeline); + }, + pipelineInfo() { + if (!this.hasRef) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status}'); + } + if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{ref}'); + } + if (!this.isMergeRequestPipeline) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} %{status} for %{mrId} with %{source}'); + } + + return s__( + 'Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{mrId} with %{source} into %{target}', + ); + }, + }, + mounted() { + Mousetrap.bind(keysFor(MR_COPY_SOURCE_BRANCH_NAME), this.handleKeyboardCopy); + }, + beforeDestroy() { + Mousetrap.unbind(keysFor(MR_COPY_SOURCE_BRANCH_NAME)); + }, + methods: { + onStageClick(stage) { + this.$emit('requestSidebarStageDropdown', stage); + }, + handleKeyboardCopy() { + let button; + + if (!this.hasRef) { + return; + } + if (!this.isTriggeredByMergeRequest) { + button = this.$refs['copy-source-ref-link']; + } else { + button = this.$refs['copy-source-branch-link']; + } + + clickCopyToClipboardButton(button.$el); + }, + }, +}; +</script> +<template> + <div class="dropdown"> + <div class="gl-display-flex gl-flex-wrap gl-gap-2 js-pipeline-info" data-testid="pipeline-info"> + <gl-sprintf :message="pipelineInfo"> + <template #bold="{ content }"> + <span class="gl-display-flex gl-font-weight-bold">{{ content }}</span> + </template> + <template #id> + <gl-link + :href="pipeline.path" + class="js-pipeline-path link-commit gl-text-blue-500!" + data-testid="pipeline-path" + >#{{ pipeline.id }}</gl-link + > + </template> + <template #status> + <ci-badge-link + :status="pipeline.details.status" + size="sm" + data-testid="pipeline-status-link" + /> + </template> + <template #mrId> + <gl-link + :href="pipeline.merge_request.path" + class="link-commit gl-text-blue-500!" + data-testid="mr-link" + >!{{ pipeline.merge_request.iid }}</gl-link + > + </template> + <template #ref> + <gl-link + :href="pipeline.ref.path" + class="link-commit ref-name gl-mt-1" + data-testid="source-ref-link" + >{{ pipeline.ref.name }}</gl-link + ><clipboard-button + ref="copy-source-ref-link" + :text="pipeline.ref.name" + :title="__('Copy reference')" + category="tertiary" + size="small" + data-testid="copy-source-ref-link" + /> + </template> + <template #source> + <gl-link + :href="pipeline.merge_request.source_branch_path" + class="link-commit ref-name gl-mt-1" + data-testid="source-branch-link" + >{{ pipeline.merge_request.source_branch }}</gl-link + ><clipboard-button + ref="copy-source-branch-link" + :text="pipeline.merge_request.source_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-source-branch-link" + /> + </template> + <template #target> + <gl-link + :href="pipeline.merge_request.target_branch_path" + class="link-commit ref-name gl-mt-1" + data-testid="target-branch-link" + >{{ pipeline.merge_request.target_branch }}</gl-link + ><clipboard-button + :text="pipeline.merge_request.target_branch" + :title="__('Copy branch name')" + category="tertiary" + size="small" + data-testid="copy-target-branch-link" + /> + </template> + </gl-sprintf> + </div> + + <gl-disclosure-dropdown + :toggle-text="selectedStage" + :items="dropdownItems" + block + class="gl-mt-2" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue new file mode 100644 index 00000000000..315587a3376 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/sidebar/trigger_block.vue @@ -0,0 +1,94 @@ +<script> +import { GlButton, GlTableLite } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const DEFAULT_TD_CLASSES = 'gl-font-sm!'; + +export default { + fields: [ + { + key: 'key', + label: __('Key'), + tdAttr: { 'data-testid': 'trigger-build-key' }, + tdClass: DEFAULT_TD_CLASSES, + }, + { + key: 'value', + label: __('Value'), + tdAttr: { 'data-testid': 'trigger-build-value' }, + tdClass: DEFAULT_TD_CLASSES, + }, + ], + components: { + GlButton, + GlTableLite, + }, + props: { + trigger: { + type: Object, + required: true, + }, + }, + data() { + return { + showVariableValues: false, + }; + }, + computed: { + hasVariables() { + return this.trigger.variables.length > 0; + }, + getToggleButtonText() { + return this.showVariableValues ? __('Hide values') : __('Reveal values'); + }, + hasValues() { + return this.trigger.variables.some((v) => v.value); + }, + }, + methods: { + toggleValues() { + this.showVariableValues = !this.showVariableValues; + }, + getDisplayValue(value) { + return this.showVariableValues ? value : '••••••'; + }, + }, +}; +</script> + +<template> + <div> + <p + v-if="trigger.short_token" + :class="{ 'gl-mb-2': hasVariables, 'gl-mb-0': !hasVariables }" + data-testid="trigger-short-token" + > + <span class="gl-font-weight-bold">{{ __('Trigger token:') }}</span> {{ trigger.short_token }} + </p> + + <template v-if="hasVariables"> + <p class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <span class="gl-display-flex gl-font-weight-bold">{{ __('Trigger variables') }}</span> + + <gl-button + v-if="hasValues" + class="gl-mt-2" + size="small" + data-testid="trigger-reveal-values-button" + @click="toggleValues" + >{{ getToggleButtonText }}</gl-button + > + </p> + + <gl-table-lite :items="trigger.variables" :fields="$options.fields" small bordered fixed> + <template #cell(key)="{ item }"> + <span class="gl-overflow-break-word">{{ item.key }}</span> + </template> + + <template #cell(value)="data"> + <span class="gl-overflow-break-word">{{ getDisplayValue(data.value) }}</span> + </template> + </gl-table-lite> + </template> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/stuck_block.vue b/app/assets/javascripts/ci/job_details/components/stuck_block.vue new file mode 100644 index 00000000000..8c73f09daea --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/stuck_block.vue @@ -0,0 +1,91 @@ +<script> +import { GlAlert, GlBadge, GlLink, GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { DOCS_URL } from 'jh_else_ce/lib/utils/url_utility'; +/** + * Renders Stuck Runners block for job's view. + */ +export default { + components: { + GlAlert, + GlBadge, + GlLink, + GlSprintf, + }, + props: { + hasOfflineRunnersForProject: { + type: Boolean, + required: true, + }, + tags: { + type: Array, + required: false, + default: () => [], + }, + runnersPath: { + type: String, + required: true, + }, + }, + computed: { + hasNoRunnersWithCorrespondingTags() { + return this.tags.length > 0; + }, + protectedBranchSettingsDocsLink() { + return `${DOCS_URL}/runner/security/index.html#reduce-the-security-risk-of-using-privileged-containers`; + }, + stuckData() { + if (this.hasNoRunnersWithCorrespondingTags) { + return { + text: s__( + `Job|This job is stuck because of one of the following problems. There are no active runners online, no runners for the %{linkStart}protected branch%{linkEnd}, or no runners that match all of the job's tags:`, + ), + dataTestId: 'job-stuck-with-tags', + showTags: true, + }; + } + if (this.hasOfflineRunnersForProject) { + return { + text: s__(`Job|This job is stuck because the project + doesn't have any runners online assigned to it.`), + dataTestId: 'job-stuck-no-runners', + showTags: false, + }; + } + + return { + text: s__(`Job|This job is stuck because you don't + have any active runners that can run this job.`), + dataTestId: 'job-stuck-no-active-runners', + showTags: false, + }; + }, + }, +}; +</script> +<template> + <gl-alert variant="warning" :dismissible="false"> + <p class="gl-mb-0" :data-testid="stuckData.dataTestId"> + <gl-sprintf :message="stuckData.text"> + <template #link="{ content }"> + <a + class="gl-display-inline-block" + :href="protectedBranchSettingsDocsLink" + target="_blank" + > + {{ content }} + </a> + </template> + </gl-sprintf> + <template v-if="stuckData.showTags"> + <gl-badge v-for="tag in tags" :key="tag" variant="info"> + {{ tag }} + </gl-badge> + </template> + </p> + {{ __('Go to project') }} + <gl-link v-if="runnersPath" :href="runnersPath"> + {{ __('CI settings') }} + </gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue new file mode 100644 index 00000000000..c9747ca9f02 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/components/unmet_prerequisites_block.vue @@ -0,0 +1,33 @@ +<script> +import { GlLink, GlAlert } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +/** + * Renders Unmet Prerequisites block for job's view. + */ +export default { + i18n: { + failMessage: s__( + 'Job|This job failed because the necessary resources were not successfully created.', + ), + moreInformation: __('More information'), + }, + components: { + GlLink, + GlAlert, + }, + props: { + helpPath: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <gl-alert variant="danger" class="gl-mt-3" :dismissible="false"> + {{ $options.i18n.failMessage }} + <gl-link :href="helpPath"> + {{ $options.i18n.moreInformation }} + </gl-link> + </gl-alert> +</template> diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql new file mode 100644 index 00000000000..7fb887b2dd4 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_job.fragment.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_variable.fragment.graphql" + +fragment BaseCiJob on CiJob { + id + manualVariables { + nodes { + ...ManualCiVariable + } + } + __typename +} diff --git a/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql new file mode 100644 index 00000000000..0479df7bc4c --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/fragments/ci_variable.fragment.graphql @@ -0,0 +1,6 @@ +fragment ManualCiVariable on CiVariable { + __typename + id + key + value +} diff --git a/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql new file mode 100644 index 00000000000..5d8a7b4c6f6 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +mutation playJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobPlay(input: { id: $id, variables: $variables }) { + job { + ...BaseCiJob + webPath + } + errors + } +} diff --git a/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql new file mode 100644 index 00000000000..cd66a30ce63 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql @@ -0,0 +1,11 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +mutation retryJobWithVariables($id: CiBuildID!, $variables: [CiVariableInput!]) { + jobRetry(input: { id: $id, variables: $variables }) { + job { + ...BaseCiJob + webPath + } + errors + } +} diff --git a/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql new file mode 100644 index 00000000000..a521ec2bb72 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/graphql/queries/get_job.query.graphql @@ -0,0 +1,12 @@ +#import "~/ci/job_details/graphql/fragments/ci_job.fragment.graphql" + +query getJob($fullPath: ID!, $id: JobID!) { + project(fullPath: $fullPath) { + id + job(id: $id) { + ...BaseCiJob + manualJob + name + } + } +} diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js new file mode 100644 index 00000000000..5a1ecf2fff3 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/index.js @@ -0,0 +1,69 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import JobApp from './job_app.vue'; +import createStore from './store'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +const initializeJobPage = (element) => { + const store = createStore(); + + // Let's start initializing the store (i.e. fetching data) right away + store.dispatch('init', element.dataset); + + const { + artifactHelpUrl, + deploymentHelpUrl, + runnerSettingsUrl, + subscriptionsMoreMinutesUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable, + } = element.dataset; + + return new Vue({ + el: element, + apolloProvider, + store, + components: { + JobApp, + }, + provide: { + projectPath, + retryOutdatedJobDocsUrl, + aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable), + }, + render(createElement) { + return createElement('job-app', { + props: { + artifactHelpUrl, + deploymentHelpUrl, + runnerSettingsUrl, + subscriptionsMoreMinutesUrl, + endpoint, + pagePath, + logState, + buildStatus, + projectPath, + }, + }); + }, + }); +}; + +export default () => { + const jobElement = document.getElementById('js-job-page'); + initializeJobPage(jobElement); +}; diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue new file mode 100644 index 00000000000..5137ebfeaa8 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -0,0 +1,349 @@ +<script> +import { GlLoadingIcon, GlIcon, GlAlert } from '@gitlab/ui'; +import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { throttle, isEmpty } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters, mapState, mapActions } from 'vuex'; +import LogTopBar from 'ee_else_ce/ci/job_details/components/job_log_controllers.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; +import { __, sprintf } from '~/locale'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import Log from '~/ci/job_details/components/log/log.vue'; +import { MANUAL_STATUS } from '~/ci/constants'; +import EmptyState from './components/empty_state.vue'; +import EnvironmentsBlock from './components/environments_block.vue'; +import ErasedBlock from './components/erased_block.vue'; +import JobHeader from './components/job_header.vue'; +import StuckBlock from './components/stuck_block.vue'; +import UnmetPrerequisitesBlock from './components/unmet_prerequisites_block.vue'; +import Sidebar from './components/sidebar/sidebar.vue'; + +export default { + name: 'JobPageApp', + components: { + JobHeader, + EmptyState, + EnvironmentsBlock, + ErasedBlock, + GlIcon, + Log, + LogTopBar, + StuckBlock, + UnmetPrerequisitesBlock, + Sidebar, + GlLoadingIcon, + SharedRunner: () => import('ee_component/ci/runner/components/shared_runner_limit_block.vue'), + GlAlert, + }, + directives: { + SafeHtml, + }, + mixins: [delayedJobMixin], + props: { + artifactHelpUrl: { + type: String, + required: false, + default: '', + }, + runnerSettingsUrl: { + type: String, + required: false, + default: null, + }, + deploymentHelpUrl: { + type: String, + required: false, + default: null, + }, + terminalPath: { + type: String, + required: false, + default: null, + }, + projectPath: { + type: String, + required: true, + }, + subscriptionsMoreMinutesUrl: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + searchResults: [], + showUpdateVariablesState: false, + }; + }, + computed: { + ...mapState([ + 'isLoading', + 'job', + 'isSidebarOpen', + 'jobLog', + 'isJobLogComplete', + 'jobLogSize', + 'isJobLogSizeVisible', + 'isScrollBottomDisabled', + 'isScrollTopDisabled', + 'isScrolledToBottomBeforeReceivingJobLog', + 'hasError', + 'selectedStage', + ]), + ...mapGetters([ + 'headerTime', + 'hasUnmetPrerequisitesFailure', + 'shouldRenderCalloutMessage', + 'shouldRenderTriggeredLabel', + 'hasEnvironment', + 'shouldRenderSharedRunnerLimitWarning', + 'hasJobLog', + 'emptyStateIllustration', + 'isScrollingDown', + 'emptyStateAction', + 'hasOfflineRunnersForProject', + ]), + + shouldRenderContent() { + return !this.isLoading && !this.hasError; + }, + + emptyStateTitle() { + const { emptyStateIllustration, remainingTime } = this; + const { title } = emptyStateIllustration; + + if (this.isDelayedJob) { + return sprintf(title, { remainingTime }); + } + + return title; + }, + + shouldRenderHeaderCallout() { + return this.shouldRenderCalloutMessage && !this.hasUnmetPrerequisitesFailure; + }, + + isJobRetryable() { + return Boolean(this.job.retry_path); + }, + + jobName() { + return sprintf(__('Job %{jobName}'), { jobName: this.job.name }); + }, + }, + watch: { + // Once the job log is loaded, + // fetch the stages for the dropdown on the sidebar + job(newVal, oldVal) { + if (isEmpty(oldVal) && !isEmpty(newVal.pipeline)) { + const stages = this.job.pipeline.details.stages || []; + + const defaultStage = stages.find((stage) => stage && stage.name === this.selectedStage); + + if (defaultStage) { + this.fetchJobsForStage(defaultStage); + } + } + + // Only poll for job log if we are not in the manual variables form empty state. + // This will be handled more elegantly in the future with GraphQL in https://gitlab.com/gitlab-org/gitlab/-/issues/389597 + if (newVal?.status?.group !== MANUAL_STATUS && !this.showUpdateVariablesState) { + this.fetchJobLog(); + } + }, + }, + created() { + this.throttled = throttle(this.toggleScrollButtons, 100); + + window.addEventListener('resize', this.onResize); + window.addEventListener('scroll', this.updateScroll); + }, + mounted() { + this.updateSidebar(); + }, + beforeDestroy() { + this.stopPollingJobLog(); + this.stopPolling(); + window.removeEventListener('resize', this.onResize); + window.removeEventListener('scroll', this.updateScroll); + }, + methods: { + ...mapActions([ + 'fetchJobLog', + 'fetchJobsForStage', + 'hideSidebar', + 'showSidebar', + 'toggleSidebar', + 'scrollBottom', + 'scrollTop', + 'stopPollingJobLog', + 'stopPolling', + 'toggleScrollButtons', + 'toggleScrollAnimation', + ]), + onHideManualVariablesForm() { + this.showUpdateVariablesState = false; + }, + onResize() { + this.updateSidebar(); + this.updateScroll(); + }, + onUpdateVariables() { + this.showUpdateVariablesState = true; + }, + updateSidebar() { + const breakpoint = bp.getBreakpointSize(); + if (breakpoint === 'xs' || breakpoint === 'sm') { + this.hideSidebar(); + } else if (!this.isSidebarOpen) { + this.showSidebar(); + } + }, + updateScroll() { + if (!isScrolledToBottom()) { + this.toggleScrollAnimation(false); + } else if (this.isScrollingDown) { + this.toggleScrollAnimation(true); + } + + this.throttled(); + }, + setSearchResults(searchResults) { + this.searchResults = searchResults; + }, + }, +}; +</script> +<template> + <div> + <gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-6" /> + + <template v-else-if="shouldRenderContent"> + <div class="build-page" data-testid="job-content"> + <!-- Header Section --> + <header> + <div class="build-header top-area"> + <job-header + :status="job.status" + :time="headerTime" + :user="job.user" + :should-render-triggered-label="shouldRenderTriggeredLabel" + :name="jobName" + @clickedSidebarButton="toggleSidebar" + /> + </div> + <gl-alert + v-if="shouldRenderHeaderCallout" + variant="danger" + class="gl-mt-3" + :dismissible="false" + > + <div v-safe-html="job.callout_message"></div> + </gl-alert> + </header> + <!-- EO Header Section --> + + <!-- Body Section --> + <stuck-block + v-if="job.stuck" + :has-offline-runners-for-project="hasOfflineRunnersForProject" + :tags="job.tags" + :runners-path="runnerSettingsUrl" + /> + + <unmet-prerequisites-block + v-if="hasUnmetPrerequisitesFailure" + :help-path="deploymentHelpUrl" + /> + + <shared-runner + v-if="shouldRenderSharedRunnerLimitWarning" + :quota-used="job.runners.quota.used" + :quota-limit="job.runners.quota.limit" + :project-path="projectPath" + :subscriptions-more-minutes-url="subscriptionsMoreMinutesUrl" + /> + + <environments-block + v-if="hasEnvironment" + :deployment-status="job.deployment_status" + :deployment-cluster="job.deployment_cluster" + :icon-status="job.status" + /> + + <erased-block + v-if="job.erased_at" + data-testid="job-erased-block" + :user="job.erased_by" + :erased-at="job.erased_at" + /> + + <div + v-if="job.archived" + class="gl-mt-3 gl-py-2 gl-px-3 gl-align-items-center gl-z-index-1 gl-m-auto archived-job" + :class="{ 'sticky-top gl-border-bottom-0': hasJobLog }" + data-testid="archived-job" + > + <gl-icon name="lock" class="gl-vertical-align-bottom" /> + {{ __('This job is archived. Only the complete pipeline can be retried.') }} + </div> + <!-- job log --> + <div + v-if="hasJobLog && !showUpdateVariablesState" + class="build-log-container gl-relative" + :class="{ 'gl-mt-3': !job.archived }" + > + <log-top-bar + :class="{ + 'has-archived-block': job.archived, + }" + :size="jobLogSize" + :raw-path="job.raw_path" + :is-scroll-bottom-disabled="isScrollBottomDisabled" + :is-scroll-top-disabled="isScrollTopDisabled" + :is-job-log-size-visible="isJobLogSizeVisible" + :is-scrolling-down="isScrollingDown" + :is-complete="isJobLogComplete" + :job-log="jobLog" + @scrollJobLogTop="scrollTop" + @scrollJobLogBottom="scrollBottom" + @searchResults="setSearchResults" + /> + <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" /> + </div> + <!-- EO job log --> + + <!-- empty state --> + <empty-state + v-if="!hasJobLog || showUpdateVariablesState" + :illustration-path="emptyStateIllustration.image" + :illustration-size-class="emptyStateIllustration.size" + :is-retryable="isJobRetryable" + :job-id="job.id" + :title="emptyStateTitle" + :content="emptyStateIllustration.content" + :action="emptyStateAction" + :playable="job.playable" + :scheduled="job.scheduled" + @hideManualVariablesForm="onHideManualVariablesForm()" + /> + <!-- EO empty state --> + + <!-- EO Body Section --> + </div> + </template> + + <sidebar + v-if="shouldRenderContent" + :class="{ + 'right-sidebar-expanded': isSidebarOpen, + 'right-sidebar-collapsed': !isSidebarOpen, + }" + :artifact-help-url="artifactHelpUrl" + data-testid="job-sidebar" + @updateVariables="onUpdateVariables()" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js new file mode 100644 index 00000000000..33d83689e61 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -0,0 +1,277 @@ +import Visibility from 'visibilityjs'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { setFaviconOverlay, resetFavicon } from '~/lib/utils/favicon'; +import { HTTP_STATUS_FORBIDDEN } from '~/lib/utils/http_status'; +import Poll from '~/lib/utils/poll'; +import { + canScroll, + isScrolledToBottom, + isScrolledToTop, + scrollDown, + scrollUp, +} from '~/lib/utils/scroll_utils'; +import { __ } from '~/locale'; +import { reportToSentry } from '~/ci/utils'; +import * as types from './mutation_types'; + +export const init = ({ dispatch }, { endpoint, logState, pagePath }) => { + dispatch('setJobEndpoint', endpoint); + dispatch('setJobLogOptions', { + logState, + pagePath, + }); + + return dispatch('fetchJob'); +}; + +export const setJobEndpoint = ({ commit }, endpoint) => commit(types.SET_JOB_ENDPOINT, endpoint); +export const setJobLogOptions = ({ commit }, options) => commit(types.SET_JOB_LOG_OPTIONS, options); + +export const hideSidebar = ({ commit }) => commit(types.HIDE_SIDEBAR); +export const showSidebar = ({ commit }) => commit(types.SHOW_SIDEBAR); + +export const toggleSidebar = ({ dispatch, state }) => { + if (state.isSidebarOpen) { + dispatch('hideSidebar'); + } else { + dispatch('showSidebar'); + } +}; + +let eTagPoll; + +export const clearEtagPoll = () => { + eTagPoll = null; +}; + +export const stopPolling = () => { + if (eTagPoll) eTagPoll.stop(); +}; + +export const restartPolling = () => { + if (eTagPoll) eTagPoll.restart(); +}; + +export const requestJob = ({ commit }) => commit(types.REQUEST_JOB); + +export const fetchJob = ({ state, dispatch }) => { + dispatch('requestJob'); + + eTagPoll = new Poll({ + resource: { + getJob(endpoint) { + return axios.get(endpoint); + }, + }, + data: state.jobEndpoint, + method: 'getJob', + successCallback: ({ data }) => dispatch('receiveJobSuccess', data), + errorCallback: () => dispatch('receiveJobError'), + }); + + if (!Visibility.hidden()) { + eTagPoll.makeRequest(); + } else { + axios + .get(state.jobEndpoint) + .then(({ data }) => dispatch('receiveJobSuccess', data)) + .catch(() => dispatch('receiveJobError')); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + dispatch('restartPolling'); + } else { + dispatch('stopPolling'); + } + }); +}; + +export const receiveJobSuccess = ({ commit }, data = {}) => { + commit(types.RECEIVE_JOB_SUCCESS, data); + + if (data.status && data.status.favicon) { + setFaviconOverlay(data.status.favicon); + } else { + resetFavicon(); + } +}; +export const receiveJobError = ({ commit }) => { + commit(types.RECEIVE_JOB_ERROR); + createAlert({ + message: __('An error occurred while fetching the job.'), + }); + resetFavicon(); +}; + +/** + * Job Log + */ +export const scrollTop = ({ dispatch }) => { + scrollUp(); + dispatch('toggleScrollButtons'); +}; + +export const scrollBottom = ({ dispatch }) => { + scrollDown(); + dispatch('toggleScrollButtons'); +}; + +/** + * Responsible for toggling the disabled state of the scroll buttons + */ +export const toggleScrollButtons = ({ dispatch }) => { + if (canScroll()) { + if (isScrolledToTop()) { + dispatch('disableScrollTop'); + dispatch('enableScrollBottom'); + } else if (isScrolledToBottom()) { + dispatch('disableScrollBottom'); + dispatch('enableScrollTop'); + } else { + dispatch('enableScrollTop'); + dispatch('enableScrollBottom'); + } + } else { + dispatch('disableScrollBottom'); + dispatch('disableScrollTop'); + } +}; + +export const disableScrollBottom = ({ commit }) => commit(types.DISABLE_SCROLL_BOTTOM); +export const disableScrollTop = ({ commit }) => commit(types.DISABLE_SCROLL_TOP); +export const enableScrollBottom = ({ commit }) => commit(types.ENABLE_SCROLL_BOTTOM); +export const enableScrollTop = ({ commit }) => commit(types.ENABLE_SCROLL_TOP); + +/** + * While the automatic scroll down is active, + * we show the scroll down button with an animation + */ +export const toggleScrollAnimation = ({ commit }, toggle) => + commit(types.TOGGLE_SCROLL_ANIMATION, toggle); + +/** + * Responsible to handle automatic scroll + */ +export const toggleScrollisInBottom = ({ commit }, toggle) => { + commit(types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG, toggle); +}; + +export const requestJobLog = ({ commit }) => commit(types.REQUEST_JOB_LOG); + +export const fetchJobLog = ({ dispatch, state }) => + // update trace endpoint once BE compeletes trace re-naming in #340626 + axios + .get(`${state.jobLogEndpoint}/trace.json`, { + params: { state: state.jobLogState }, + }) + .then(({ data }) => { + dispatch('toggleScrollisInBottom', isScrolledToBottom()); + dispatch('receiveJobLogSuccess', data); + + if (data.complete) { + dispatch('stopPollingJobLog'); + } else if (!state.jobLogTimeout) { + dispatch('startPollingJobLog'); + } + }) + .catch((e) => { + if (e.response.status === HTTP_STATUS_FORBIDDEN) { + dispatch('receiveJobLogUnauthorizedError'); + } else { + reportToSentry('job_actions', e); + dispatch('receiveJobLogError'); + } + }); + +export const startPollingJobLog = ({ dispatch, commit }) => { + const jobLogTimeout = setTimeout(() => { + commit(types.SET_JOB_LOG_TIMEOUT, 0); + dispatch('fetchJobLog'); + }, 4000); + + commit(types.SET_JOB_LOG_TIMEOUT, jobLogTimeout); +}; + +export const stopPollingJobLog = ({ state, commit }) => { + clearTimeout(state.jobLogTimeout); + commit(types.SET_JOB_LOG_TIMEOUT, 0); + commit(types.STOP_POLLING_JOB_LOG); +}; + +export const receiveJobLogSuccess = ({ commit }, log) => commit(types.RECEIVE_JOB_LOG_SUCCESS, log); + +export const receiveJobLogError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); + createAlert({ + message: __('An error occurred while fetching the job log.'), + }); +}; + +export const receiveJobLogUnauthorizedError = ({ dispatch }) => { + dispatch('stopPollingJobLog'); + createAlert({ + message: __('The current user is not authorized to access the job log.'), + }); +}; +/** + * When the user clicks a collapsible line in the job + * log, we commit a mutation to update the state + * + * @param {Object} section + */ +export const toggleCollapsibleLine = ({ commit }, section) => + commit(types.TOGGLE_COLLAPSIBLE_LINE, section); + +/** + * Jobs list on sidebar - depend on stages dropdown + */ +export const requestJobsForStage = ({ commit }, stage) => + commit(types.REQUEST_JOBS_FOR_STAGE, stage); + +// On stage click, set selected stage + fetch job +export const fetchJobsForStage = ({ dispatch }, stage = {}) => { + dispatch('requestJobsForStage', stage); + + axios + .get(stage.dropdown_path, { + params: { + retried: 1, + }, + }) + .then(({ data }) => { + const retriedJobs = data.retried.map((job) => ({ ...job, retried: true })); + const jobs = data.latest_statuses.concat(retriedJobs); + + dispatch('receiveJobsForStageSuccess', jobs); + }) + .catch(() => dispatch('receiveJobsForStageError')); +}; +export const receiveJobsForStageSuccess = ({ commit }, data) => + commit(types.RECEIVE_JOBS_FOR_STAGE_SUCCESS, data); + +export const receiveJobsForStageError = ({ commit }) => { + commit(types.RECEIVE_JOBS_FOR_STAGE_ERROR); + createAlert({ + message: __('An error occurred while fetching the jobs.'), + }); +}; + +export const triggerManualJob = ({ state }, variables) => { + const parsedVariables = variables.map((variable) => { + const copyVar = { ...variable }; + delete copyVar.id; + return copyVar; + }); + + axios + .post(state.job.status.action.path, { + job_variables_attributes: parsedVariables, + }) + .catch(() => + createAlert({ + message: __('An error occurred while triggering the job.'), + }), + ); +}; diff --git a/app/assets/javascripts/ci/job_details/store/getters.js b/app/assets/javascripts/ci/job_details/store/getters.js new file mode 100644 index 00000000000..a0f9db7409d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/getters.js @@ -0,0 +1,50 @@ +import { isEmpty } from 'lodash'; +import { isScrolledToBottom } from '~/lib/utils/scroll_utils'; + +export const headerTime = (state) => state.job.started_at || state.job.created_at; + +export const hasForwardDeploymentFailure = (state) => + state?.job?.failure_reason === 'forward_deployment_failure'; + +export const hasUnmetPrerequisitesFailure = (state) => + state?.job?.failure_reason === 'unmet_prerequisites'; + +export const shouldRenderCalloutMessage = (state) => + !isEmpty(state.job.status) && !isEmpty(state.job.callout_message); + +/** + * When the job has not started the value of job.started_at will be null + * When job has started the value of job.started_at will be a string with a date. + */ +export const shouldRenderTriggeredLabel = (state) => Boolean(state.job.started_at); + +export const hasEnvironment = (state) => !isEmpty(state.job.deployment_status); + +/** + * Checks if it the job has a log. + * Used to check if it should render the job log or the empty state + * @returns {Boolean} + */ +export const hasJobLog = (state) => + // update has_trace once BE compeletes trace re-naming in #340626 + state.job.has_trace || (!isEmpty(state.job.status) && state.job.status.group === 'running'); + +export const emptyStateIllustration = (state) => state?.job?.status?.illustration || {}; + +export const emptyStateAction = (state) => state?.job?.status?.action || null; + +/** + * Shared runners limit is only rendered when + * used quota is bigger or equal than the limit + * + * @returns {Boolean} + */ +export const shouldRenderSharedRunnerLimitWarning = (state) => + !isEmpty(state.job.runners) && + !isEmpty(state.job.runners.quota) && + state.job.runners.quota.used >= state.job.runners.quota.limit; + +export const isScrollingDown = (state) => isScrolledToBottom() && !state.isJobLogComplete; + +export const hasOfflineRunnersForProject = (state) => + state?.job?.runners?.available && !state?.job?.runners?.online; diff --git a/app/assets/javascripts/ci/job_details/store/index.js b/app/assets/javascripts/ci/job_details/store/index.js new file mode 100644 index 00000000000..b9d76765d8d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export default () => + new Vuex.Store({ + actions, + mutations, + getters, + state: state(), + }); diff --git a/app/assets/javascripts/ci/job_details/store/mutation_types.js b/app/assets/javascripts/ci/job_details/store/mutation_types.js new file mode 100644 index 00000000000..4915a826b84 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/mutation_types.js @@ -0,0 +1,31 @@ +export const SET_JOB_ENDPOINT = 'SET_JOB_ENDPOINT'; +export const SET_JOB_LOG_OPTIONS = 'SET_JOB_LOG_OPTIONS'; + +export const HIDE_SIDEBAR = 'HIDE_SIDEBAR'; +export const SHOW_SIDEBAR = 'SHOW_SIDEBAR'; + +export const SCROLL_TO_TOP = 'SCROLL_TO_TOP'; +export const SCROLL_TO_BOTTOM = 'SCROLL_TO_BOTTOM'; +export const DISABLE_SCROLL_BOTTOM = 'DISABLE_SCROLL_BOTTOM'; +export const DISABLE_SCROLL_TOP = 'DISABLE_SCROLL_TOP'; +export const ENABLE_SCROLL_BOTTOM = 'ENABLE_SCROLL_BOTTOM'; +export const ENABLE_SCROLL_TOP = 'ENABLE_SCROLL_TOP'; +export const TOGGLE_SCROLL_ANIMATION = 'TOGGLE_SCROLL_ANIMATION'; + +export const TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG = 'TOGGLE_IS_SCROLL_IN_BOTTOM'; + +export const REQUEST_JOB = 'REQUEST_JOB'; +export const RECEIVE_JOB_SUCCESS = 'RECEIVE_JOB_SUCCESS'; +export const RECEIVE_JOB_ERROR = 'RECEIVE_JOB_ERROR'; + +export const REQUEST_JOB_LOG = 'REQUEST_JOB_LOG'; +export const SET_JOB_LOG_TIMEOUT = 'SET_JOB_LOG_TIMEOUT'; +export const STOP_POLLING_JOB_LOG = 'STOP_POLLING_JOB_LOG'; +export const RECEIVE_JOB_LOG_SUCCESS = 'RECEIVE_JOB_LOG_SUCCESS'; +export const RECEIVE_JOB_LOG_ERROR = 'RECEIVE_JOB_LOG_ERROR'; +export const TOGGLE_COLLAPSIBLE_LINE = 'TOGGLE_COLLAPSIBLE_LINE'; + +export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE'; +export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE'; +export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS'; +export const RECEIVE_JOBS_FOR_STAGE_ERROR = 'RECEIVE_JOBS_FOR_STAGE_ERROR'; diff --git a/app/assets/javascripts/ci/job_details/store/mutations.js b/app/assets/javascripts/ci/job_details/store/mutations.js new file mode 100644 index 00000000000..b7d7006ee61 --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/mutations.js @@ -0,0 +1,134 @@ +import Vue from 'vue'; +import * as types from './mutation_types'; +import { logLinesParser, updateIncrementalJobLog } from './utils'; + +export default { + [types.SET_JOB_ENDPOINT](state, endpoint) { + state.jobEndpoint = endpoint; + }, + + [types.SET_JOB_LOG_OPTIONS](state, options = {}) { + state.jobLogEndpoint = options.pagePath; + state.jobLogState = options.logState; + }, + + [types.HIDE_SIDEBAR](state) { + state.isSidebarOpen = false; + }, + [types.SHOW_SIDEBAR](state) { + state.isSidebarOpen = true; + }, + + [types.RECEIVE_JOB_LOG_SUCCESS](state, log = {}) { + if (log.state) { + state.jobLogState = log.state; + } + + if (log.append) { + state.jobLog = log.lines ? updateIncrementalJobLog(log.lines, state.jobLog) : state.jobLog; + + state.jobLogSize += log.size; + } else { + // When the job still does not have a log + // the job log response will not have a defined + // html or size. We keep the old value otherwise these + // will be set to `null` + state.jobLog = log.lines ? logLinesParser(log.lines, [], window.location.hash) : state.jobLog; + + state.jobLogSize = log.size || state.jobLogSize; + } + + if (state.jobLogSize < log.total) { + state.isJobLogSizeVisible = true; + } else { + state.isJobLogSizeVisible = false; + } + + state.isJobLogComplete = log.complete || state.isJobLogComplete; + }, + + [types.SET_JOB_LOG_TIMEOUT](state, id) { + state.jobLogTimeout = id; + }, + + /** + * Will remove loading animation + */ + [types.STOP_POLLING_JOB_LOG](state) { + state.isJobLogComplete = true; + }, + + /** + * Instead of filtering the array of lines to find the one that must be updated + * we use Vue.set to make this process more performant + * + * https://vuex.vuejs.org/guide/mutations.html#mutations-follow-vue-s-reactivity-rules + * @param {Object} state + * @param {Object} section + */ + [types.TOGGLE_COLLAPSIBLE_LINE](state, section) { + Vue.set(section, 'isClosed', !section.isClosed); + }, + + [types.REQUEST_JOB](state) { + state.isLoading = true; + }, + [types.RECEIVE_JOB_SUCCESS](state, job) { + state.hasError = false; + state.isLoading = false; + state.job = job; + + state.stages = + job.pipeline && job.pipeline.details && job.pipeline.details.stages + ? job.pipeline.details.stages + : []; + + /** + * We only update it on the first request + * The dropdown can be changed by the user + * after the first request, + * and we do not want to hijack that + */ + if (state.selectedStage === '' && job.stage) { + state.selectedStage = job.stage; + } + }, + [types.RECEIVE_JOB_ERROR](state) { + state.isLoading = false; + state.job = {}; + state.hasError = true; + }, + + [types.ENABLE_SCROLL_TOP](state) { + state.isScrollTopDisabled = false; + }, + [types.DISABLE_SCROLL_TOP](state) { + state.isScrollTopDisabled = true; + }, + [types.ENABLE_SCROLL_BOTTOM](state) { + state.isScrollBottomDisabled = false; + }, + [types.DISABLE_SCROLL_BOTTOM](state) { + state.isScrollBottomDisabled = true; + }, + [types.TOGGLE_SCROLL_ANIMATION](state, toggle) { + state.isScrollingDown = toggle; + }, + + [types.TOGGLE_IS_SCROLL_IN_BOTTOM_BEFORE_UPDATING_JOB_LOG](state, toggle) { + state.isScrolledToBottomBeforeReceivingJobLog = toggle; + }, + + [types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) { + state.isLoadingJobs = true; + state.selectedStage = stage.name; + }, + [types.RECEIVE_JOBS_FOR_STAGE_SUCCESS](state, jobs) { + state.isLoadingJobs = false; + state.jobs = jobs; + }, + [types.RECEIVE_JOBS_FOR_STAGE_ERROR](state) { + state.isLoadingJobs = false; + state.jobs = []; + }, +}; diff --git a/app/assets/javascripts/ci/job_details/store/state.js b/app/assets/javascripts/ci/job_details/store/state.js new file mode 100644 index 00000000000..dfff65c364d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/state.js @@ -0,0 +1,33 @@ +export default () => ({ + jobEndpoint: null, + jobLogEndpoint: null, + + // sidebar + isSidebarOpen: true, + + isLoading: false, + hasError: false, + job: {}, + + // scroll buttons state + isScrollBottomDisabled: true, + isScrollTopDisabled: true, + + // Used to check if we should keep the automatic scroll + isScrolledToBottomBeforeReceivingJobLog: true, + + jobLog: [], + isJobLogComplete: false, + jobLogSize: 0, + isJobLogSizeVisible: false, + jobLogTimeout: 0, + + // used as a query parameter to fetch the job log + jobLogState: null, + + // sidebar dropdown & list of jobs + isLoadingJobs: false, + selectedStage: '', + stages: [], + jobs: [], +}); diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js new file mode 100644 index 00000000000..bc76901026d --- /dev/null +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -0,0 +1,195 @@ +import { parseBoolean } from '~/lib/utils/common_utils'; + +/** + * Adds the line number property + * @param Object line + * @param Number lineNumber + */ +export const parseLine = (line = {}, lineNumber) => ({ + ...line, + lineNumber, +}); + +/** + * When a line has `section_header` set to true, we create a new + * structure to allow to nest the lines that belong to the + * collapsible section + * + * @param Object line + * @param Number lineNumber + */ +export const parseHeaderLine = (line = {}, lineNumber, hash) => { + // if a hash is present in the URL then we ensure + // all sections are visible so we can scroll to the hash + // in the DOM + if (hash) { + return { + isClosed: false, + isHeader: true, + line: parseLine(line, lineNumber), + lines: [], + }; + } + + return { + isClosed: parseBoolean(line.section_options?.collapsed), + isHeader: true, + line: parseLine(line, lineNumber), + lines: [], + }; +}; + +/** + * Finds the matching header section + * for the section_duration object and adds it to it + * + * { + * isHeader: true, + * line: { + * content: [], + * lineNumber: 0, + * section_duration: "", + * }, + * lines: [] + * } + * + * @param Array data + * @param Object durationLine + */ +export function addDurationToHeader(data, durationLine) { + data.forEach((el) => { + if (el.line && el.line.section === durationLine.section) { + el.line.section_duration = durationLine.section_duration; + } + }); +} + +/** + * Check is the current section belongs to a collapsible section + * + * @param Array acc + * @param Object last + * @param Object section + * + * @returns Boolean + */ +export const isCollapsibleSection = (acc = [], last = {}, section = {}) => + acc.length > 0 && + last.isHeader === true && + !section.section_duration && + section.section === last.line.section; + +/** + * Returns the lineNumber of the last line in + * a parsed log + * + * @param Array acc + * @returns Number + */ +export const getIncrementalLineNumber = (acc) => { + let lineNumberValue; + const lastIndex = acc.length - 1; + const lastElement = acc[lastIndex]; + const nestedLines = lastElement.lines; + + if (lastElement.isHeader && !nestedLines.length && lastElement.line) { + lineNumberValue = lastElement.line.lineNumber; + } else if (lastElement.isHeader && nestedLines.length) { + lineNumberValue = nestedLines[nestedLines.length - 1].lineNumber; + } else { + lineNumberValue = lastElement.lineNumber; + } + + return lineNumberValue === 0 ? 1 : lineNumberValue + 1; +}; + +/** + * Parses the job log content into a structure usable by the template + * + * For collaspible lines (section_header = true): + * - creates a new array to hold the lines that are collapsible, + * - adds a isClosed property to handle toggle + * - adds a isHeader property to handle template logic + * - adds the section_duration + * For each line: + * - adds the index as lineNumber + * + * @param Array lines + * @param Array accumulator + * @returns Array parsed log lines + */ +export const logLinesParser = (lines = [], accumulator = [], hash = '') => + lines.reduce( + (acc, line, index) => { + const lineNumber = accumulator.length > 0 ? getIncrementalLineNumber(acc) : index; + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber, hash)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formatted header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } + + return acc; + }, + [...accumulator], + ); + +/** + * Finds the repeated offset, removes the old one + * + * Returns a new array with the updated log without + * the repeated offset + * + * @param Array newLog + * @param Array oldParsed + * @returns Array + * + */ +export const findOffsetAndRemove = (newLog = [], oldParsed = []) => { + const cloneOldLog = [...oldParsed]; + const lastIndex = cloneOldLog.length - 1; + const last = cloneOldLog[lastIndex]; + + const firstNew = newLog[0]; + + if (last && firstNew) { + if (last.offset === firstNew.offset || (last.line && last.line.offset === firstNew.offset)) { + cloneOldLog.splice(lastIndex); + } else if (last.lines && last.lines.length) { + const lastNestedIndex = last.lines.length - 1; + const lastNested = last.lines[lastNestedIndex]; + if (lastNested.offset === firstNew.offset) { + last.lines.splice(lastNestedIndex); + } + } + } + + return cloneOldLog; +}; + +/** + * When the job log is not complete, backend may send the last received line + * in the new response. + * + * We need to check if that is the case by looking for the offset property + * before parsing the incremental part + * + * @param array oldLog + * @param array newLog + */ +export const updateIncrementalJobLog = (newLog = [], oldParsed = []) => { + const parsedLog = findOffsetAndRemove(newLog, oldParsed); + + return logLinesParser(newLog, parsedLog); +}; diff --git a/app/assets/javascripts/ci/job_details/utils.js b/app/assets/javascripts/ci/job_details/utils.js new file mode 100644 index 00000000000..4d06c241b4f --- /dev/null +++ b/app/assets/javascripts/ci/job_details/utils.js @@ -0,0 +1,29 @@ +export const compactJobLog = (jobLog) => { + const compactedLog = []; + + jobLog.forEach((obj) => { + // push header section line + if (obj.line && obj.isHeader) { + compactedLog.push(obj.line); + } + + // push lines within section header + if (obj.lines?.length > 0) { + compactedLog.push(...obj.lines); + } + + // push lines from plain log + if (!obj.lines && obj.content.length > 0) { + compactedLog.push(obj); + } + }); + + return compactedLog; +}; + +export const filterAnnotations = (annotations, type) => { + return [...annotations] + .sort((a, b) => a.name.localeCompare(b.name)) + .flatMap((annotationList) => annotationList.data) + .flatMap((annotation) => annotation[type] ?? []); +}; diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue new file mode 100644 index 00000000000..609f2790869 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/actions_cell.vue @@ -0,0 +1,265 @@ +<script> +import { + GlButton, + GlButtonGroup, + GlModal, + GlModalDirective, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { reportMessageToSentry } from '~/ci/utils'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { + ACTIONS_DOWNLOAD_ARTIFACTS, + ACTIONS_START_NOW, + ACTIONS_UNSCHEDULE, + ACTIONS_PLAY, + ACTIONS_RETRY, + ACTIONS_RUN_AGAIN, + CANCEL, + GENERIC_ERROR, + JOB_SCHEDULED, + JOB_SUCCESS, + PLAY_JOB_CONFIRMATION_MESSAGE, + RUN_JOB_NOW_HEADER_TITLE, + FILE_TYPE_ARCHIVE, +} from '../../constants'; +import eventHub from '../../event_hub'; +import cancelJobMutation from '../../graphql/mutations/job_cancel.mutation.graphql'; +import playJobMutation from '../../graphql/mutations/job_play.mutation.graphql'; +import retryJobMutation from '../../graphql/mutations/job_retry.mutation.graphql'; +import unscheduleJobMutation from '../../graphql/mutations/job_unschedule.mutation.graphql'; + +export default { + ACTIONS_DOWNLOAD_ARTIFACTS, + ACTIONS_START_NOW, + ACTIONS_UNSCHEDULE, + ACTIONS_PLAY, + ACTIONS_RETRY, + CANCEL, + GENERIC_ERROR, + PLAY_JOB_CONFIRMATION_MESSAGE, + RUN_JOB_NOW_HEADER_TITLE, + jobRetry: 'jobRetry', + jobCancel: 'jobCancel', + jobPlay: 'jobPlay', + jobUnschedule: 'jobUnschedule', + playJobModalId: 'play-job-modal', + name: 'JobActionsCell', + components: { + GlButton, + GlButtonGroup, + GlCountdown, + GlModal, + GlSprintf, + }, + directives: { + GlModalDirective, + GlTooltip: GlTooltipDirective, + }, + inject: { + admin: { + default: false, + }, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + data() { + return { + retryBtnDisabled: false, + cancelBtnDisabled: false, + playManualBtnDisabled: false, + unscheduleBtnDisabled: false, + }; + }, + computed: { + hasArtifacts() { + return this.job.artifacts.nodes.find((artifact) => artifact.fileType === FILE_TYPE_ARCHIVE); + }, + artifactDownloadPath() { + return this.hasArtifacts.downloadPath; + }, + canReadJob() { + return this.job.userPermissions?.readBuild; + }, + canUpdateJob() { + return this.job.userPermissions?.updateBuild; + }, + canReadArtifacts() { + return this.job.userPermissions?.readJobArtifacts; + }, + isActive() { + return this.job.active; + }, + manualJobPlayable() { + return this.job.playable && !this.admin && this.job.manualJob; + }, + isRetryable() { + return this.job.retryable; + }, + isScheduled() { + return this.job.status === JOB_SCHEDULED; + }, + scheduledAt() { + return this.job.scheduledAt; + }, + currentJobActionPath() { + return this.job.detailedStatus?.action?.path; + }, + currentJobMethod() { + return this.job.detailedStatus?.action?.method; + }, + shouldDisplayArtifacts() { + return this.canReadArtifacts && this.hasArtifacts; + }, + retryButtonTitle() { + return this.job.status === JOB_SUCCESS ? ACTIONS_RUN_AGAIN : ACTIONS_RETRY; + }, + }, + methods: { + async postJobAction(name, mutation, redirect = false) { + try { + const { + data: { + [name]: { errors, job }, + }, + } = await this.$apollo.mutate({ + mutation, + variables: { id: this.job.id }, + }); + if (errors.length > 0) { + reportMessageToSentry(this.$options.name, errors.join(', '), {}); + this.showToastMessage(); + } else if (redirect) { + // Retry and Play actions redirect to job detail view + // we don't need to refetch with jobActionPerformed event + redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated + } else { + eventHub.$emit('jobActionPerformed'); + } + } catch (failure) { + reportMessageToSentry(this.$options.name, failure, {}); + this.showToastMessage(); + } + }, + showToastMessage() { + const toastProps = { + text: this.$options.GENERIC_ERROR, + variant: 'danger', + }; + + this.$toast.show(toastProps.text, { + variant: toastProps.variant, + }); + }, + cancelJob() { + this.cancelBtnDisabled = true; + + this.postJobAction(this.$options.jobCancel, cancelJobMutation); + }, + retryJob() { + this.retryBtnDisabled = true; + + this.postJobAction(this.$options.jobRetry, retryJobMutation, true); + }, + playJob() { + this.playManualBtnDisabled = true; + + this.postJobAction(this.$options.jobPlay, playJobMutation, true); + }, + unscheduleJob() { + this.unscheduleBtnDisabled = true; + + this.postJobAction(this.$options.jobUnschedule, unscheduleJobMutation); + }, + }, +}; +</script> + +<template> + <gl-button-group> + <template v-if="canReadJob && canUpdateJob"> + <gl-button + v-if="isActive" + v-gl-tooltip + icon="cancel" + :title="$options.CANCEL" + :aria-label="$options.CANCEL" + :disabled="cancelBtnDisabled" + data-testid="cancel-button" + @click="cancelJob()" + /> + <template v-else-if="isScheduled"> + <gl-button icon="planning" disabled data-testid="countdown"> + <gl-countdown :end-date-string="scheduledAt" /> + </gl-button> + <gl-button + v-gl-modal-directive="$options.playJobModalId" + v-gl-tooltip + icon="play" + :title="$options.ACTIONS_START_NOW" + :aria-label="$options.ACTIONS_START_NOW" + data-testid="play-scheduled" + /> + <gl-modal + :modal-id="$options.playJobModalId" + :title="$options.RUN_JOB_NOW_HEADER_TITLE" + @primary="playJob()" + > + <gl-sprintf :message="$options.PLAY_JOB_CONFIRMATION_MESSAGE"> + <template #job_name>{{ job.name }}</template> + </gl-sprintf> + </gl-modal> + <gl-button + v-gl-tooltip + icon="time-out" + :title="$options.ACTIONS_UNSCHEDULE" + :aria-label="$options.ACTIONS_UNSCHEDULE" + :disabled="unscheduleBtnDisabled" + data-testid="unschedule" + @click="unscheduleJob()" + /> + </template> + <template v-else> + <!--Note: This is the manual job play button --> + <gl-button + v-if="manualJobPlayable" + v-gl-tooltip + icon="play" + :title="$options.ACTIONS_PLAY" + :aria-label="$options.ACTIONS_PLAY" + :disabled="playManualBtnDisabled" + data-testid="play" + @click="playJob()" + /> + <gl-button + v-else-if="isRetryable" + v-gl-tooltip + icon="retry" + :title="retryButtonTitle" + :aria-label="retryButtonTitle" + :method="currentJobMethod" + :disabled="retryBtnDisabled" + data-testid="retry" + @click="retryJob()" + /> + </template> + </template> + <gl-button + v-if="shouldDisplayArtifacts" + v-gl-tooltip + icon="download" + :title="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :aria-label="$options.ACTIONS_DOWNLOAD_ARTIFACTS" + :href="artifactDownloadPath" + rel="nofollow" + download + data-testid="download-artifacts" + /> + </gl-button-group> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue new file mode 100644 index 00000000000..dbf1dfe7a29 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/duration_cell.vue @@ -0,0 +1,52 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import { formatTime } from '~/lib/utils/datetime_utility'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + iconSize: 12, + components: { + GlIcon, + TimeAgoTooltip, + }, + mixins: [timeagoMixin], + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + finishedTime() { + return this.job?.finishedAt; + }, + duration() { + return this.job?.duration; + }, + durationFormatted() { + return formatTime(this.duration * 1000); + }, + hasDurationAndFinishedTime() { + return this.finishedTime && this.duration; + }, + }, +}; +</script> + +<template> + <div> + <div v-if="duration" data-testid="job-duration"> + <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> + {{ durationFormatted }} + </div> + <div + v-if="finishedTime" + :class="{ 'gl-mt-2': hasDurationAndFinishedTime }" + data-testid="job-finished-time" + > + <gl-icon name="calendar" :size="$options.iconSize" data-testid="finished-time-icon" /> + <time-ago-tooltip :time="finishedTime" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue new file mode 100644 index 00000000000..b435eb283fd --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -0,0 +1,171 @@ +<script> +import { GlBadge, GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { s__ } from '~/locale'; +import { SUCCESS_STATUS } from '../../../constants'; + +export default { + iconSize: 12, + badgeSize: 'sm', + i18n: { + stuckText: s__('Jobs|Job is stuck. Check runners.'), + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlBadge, + GlIcon, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + jobId() { + const id = getIdFromGraphQLId(this.job.id); + return `#${id}`; + }, + jobPath() { + return this.job.detailedStatus?.detailsPath; + }, + jobRef() { + return this.job?.refName; + }, + jobRefPath() { + return this.job?.refPath; + }, + jobTags() { + return this.job.tags; + }, + createdByTag() { + return this.job.createdByTag; + }, + triggered() { + return this.job.triggered; + }, + isManualJob() { + return this.job.manualJob; + }, + successfulJob() { + return this.job.status === SUCCESS_STATUS; + }, + showAllowedToFailBadge() { + return this.job.allowFailure && !this.successfulJob; + }, + isScheduledJob() { + return Boolean(this.job.scheduledAt); + }, + canReadJob() { + return this.job?.userPermissions?.readBuild; + }, + jobStuck() { + return this.job?.stuck; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-text-truncate gl-p-3 gl-mt-n3 gl-mx-n3 gl-mb-n2"> + <gl-link + v-if="canReadJob" + class="gl-text-blue-600!" + :href="jobPath" + data-testid="job-id-link" + > + {{ jobId }} + </gl-link> + + <span v-else data-testid="job-id-limited-access">{{ jobId }}</span> + + <gl-icon + v-if="jobStuck" + v-gl-tooltip="$options.i18n.stuckText" + name="warning" + :size="$options.iconSize" + data-testid="stuck-icon" + /> + + <div + class="gl-display-flex gl-text-gray-700 gl-align-items-center gl-lg-justify-content-start gl-justify-content-end gl-mt-2" + > + <div + v-if="jobRef" + class="gl-p-2 gl-rounded-base gl-bg-gray-50 gl-max-w-15 gl-text-truncate" + > + <gl-icon + v-if="createdByTag" + name="label" + :size="$options.iconSize" + data-testid="label-icon" + /> + <gl-icon v-else name="fork" :size="$options.iconSize" data-testid="fork-icon" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.refPath" + data-testid="job-ref" + >{{ job.refName }}</gl-link + > + </div> + + <span v-else>{{ __('none') }}</span> + <div class="gl-ml-2 gl-p-2 gl-rounded-base gl-bg-gray-50"> + <gl-icon class="gl-mx-2" name="commit" :size="$options.iconSize" /> + <gl-link + class="gl-font-sm gl-font-monospace gl-text-gray-700 gl-hover-text-gray-900" + :href="job.commitPath" + data-testid="job-sha" + >{{ job.shortSha }}</gl-link + > + </div> + </div> + </div> + + <div> + <gl-badge + v-for="tag in jobTags" + :key="tag" + variant="info" + :size="$options.badgeSize" + data-testid="job-tag-badge" + > + {{ tag }} + </gl-badge> + + <gl-badge + v-if="triggered" + variant="info" + :size="$options.badgeSize" + data-testid="triggered-job-badge" + >{{ s__('Job|triggered') }} + </gl-badge> + <gl-badge + v-if="showAllowedToFailBadge" + variant="warning" + :size="$options.badgeSize" + data-testid="fail-job-badge" + >{{ s__('Job|allowed to fail') }} + </gl-badge> + <gl-badge + v-if="isScheduledJob" + variant="info" + :size="$options.badgeSize" + data-testid="delayed-job-badge" + >{{ s__('Job|delayed') }} + </gl-badge> + <gl-badge + v-if="isManualJob" + variant="info" + :size="$options.badgeSize" + data-testid="manual-job-badge" + > + {{ s__('Job|manual') }} + </gl-badge> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue new file mode 100644 index 00000000000..18d68ee8a29 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/pipeline_cell.vue @@ -0,0 +1,56 @@ +<script> +import { GlAvatar, GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; + +export default { + components: { + GlAvatar, + GlLink, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + computed: { + pipelineId() { + const id = getIdFromGraphQLId(this.job.pipeline.id); + return `#${id}`; + }, + pipelinePath() { + return this.job.pipeline?.path; + }, + pipelineUserAvatar() { + return this.job.pipeline?.user?.avatarUrl; + }, + userPath() { + return this.job.pipeline?.user?.webPath; + }, + showAvatar() { + return this.job.pipeline?.user; + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-p-3 gl-mt-n3"> + <gl-link + class="gl-text-truncate gl-ml-n3 gl-text-gray-500!" + :href="pipelinePath" + data-testid="pipeline-id" + > + {{ pipelineId }} + </gl-link> + </div> + <div class="gl-font-sm gl-text-secondary gl-mt-n2"> + <span>{{ __('created by') }}</span> + <gl-link v-if="showAvatar" :href="userPath" data-testid="pipeline-user-link"> + <gl-avatar :src="pipelineUserAvatar" :size="16" /> + </gl-link> + <span v-else>{{ __('API') }}</span> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue new file mode 100644 index 00000000000..23100a3f3db --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table.vue @@ -0,0 +1,112 @@ +<script> +import { GlTable } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import ProjectCell from '~/ci/admin/jobs_table/components/cells/project_cell.vue'; +import RunnerCell from '~/ci/admin/jobs_table/components/cells/runner_cell.vue'; +import { DEFAULT_FIELDS } from '../constants'; +import ActionsCell from './job_cells/actions_cell.vue'; +import DurationCell from './job_cells/duration_cell.vue'; +import JobCell from './job_cells/job_cell.vue'; +import PipelineCell from './job_cells/pipeline_cell.vue'; + +export default { + i18n: { + emptyText: s__('Jobs|No jobs to show'), + }, + components: { + ActionsCell, + CiBadgeLink, + DurationCell, + GlTable, + JobCell, + PipelineCell, + ProjectCell, + RunnerCell, + }, + props: { + jobs: { + type: Array, + required: true, + }, + tableFields: { + type: Array, + required: false, + default: () => DEFAULT_FIELDS, + }, + admin: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + formatCoverage(coverage) { + return coverage ? `${coverage}%` : ''; + }, + }, +}; +</script> + +<template> + <gl-table + :items="jobs" + :fields="tableFields" + :tbody-tr-attr="{ 'data-testid': 'jobs-table-row' }" + :empty-text="$options.i18n.emptyText" + data-testid="jobs-table" + show-empty + stacked="lg" + fixed + > + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(status)="{ item }"> + <ci-badge-link :status="item.detailedStatus" /> + </template> + + <template #cell(job)="{ item }"> + <job-cell :job="item" /> + </template> + + <template #cell(pipeline)="{ item }"> + <pipeline-cell :job="item" /> + </template> + + <template v-if="admin" #cell(project)="{ item }"> + <project-cell :job="item" /> + </template> + + <template v-if="admin" #cell(runner)="{ item }"> + <runner-cell :job="item" /> + </template> + + <template #cell(stage)="{ item }"> + <div class="gl-text-truncate"> + <span v-if="item.stage" data-testid="job-stage-name">{{ item.stage.name }}</span> + </div> + </template> + + <template #cell(name)="{ item }"> + <div class="gl-text-truncate"> + <span data-testid="job-name">{{ item.name }}</span> + </div> + </template> + + <template #cell(duration)="{ item }"> + <duration-cell :job="item" /> + </template> + + <template #cell(coverage)="{ item }"> + <span v-if="item.coverage" data-testid="job-coverage">{{ + formatCoverage(item.coverage) + }}</span> + </template> + + <template #cell(actions)="{ item }"> + <actions-cell class="gl-float-right" :job="item" /> + </template> + </gl-table> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue new file mode 100644 index 00000000000..d2cd27be034 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_empty_state.vue @@ -0,0 +1,36 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + i18n: { + title: s__('Jobs|Use jobs to automate your tasks'), + description: s__( + 'Jobs|Jobs are the building blocks of a GitLab CI/CD pipeline. Each job has a specific task, like testing code. To set up jobs in a CI/CD pipeline, add a CI/CD configuration file to your project.', + ), + buttonText: s__('Jobs|Create CI/CD configuration file'), + }, + components: { + GlEmptyState, + }, + inject: { + pipelineEditorPath: { + default: '', + }, + emptyStateSvgPath: { + default: '', + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :description="$options.i18n.description" + :svg-path="emptyStateSvgPath" + :primary-button-link="pipelineEditorPath" + :primary-button-text="$options.i18n.buttonText" + data-testid="jobs-empty-state" + /> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue new file mode 100644 index 00000000000..b753195da9a --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/components/jobs_table_tabs.vue @@ -0,0 +1,88 @@ +<script> +import { GlBadge, GlTab, GlTabs, GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import CancelJobs from '~/ci/admin/jobs_table/components/cancel_jobs.vue'; +import { limitedCounterWithDelimiter } from '~/lib/utils/text_utility'; + +export default { + components: { + GlBadge, + GlTab, + GlTabs, + GlLoadingIcon, + CancelJobs, + }, + inject: { + jobStatuses: { + default: {}, + }, + url: { + type: String, + default: '', + }, + }, + props: { + allJobsCount: { + type: Number, + required: true, + }, + loading: { + type: Boolean, + required: true, + }, + showCancelAllJobsButton: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + tabs() { + return [ + { + text: s__('Jobs|All'), + count: limitedCounterWithDelimiter(this.allJobsCount), + scope: null, + testId: 'jobs-all-tab', + showBadge: true, + }, + { + text: s__('Jobs|Finished'), + scope: [this.jobStatuses.success, this.jobStatuses.failed, this.jobStatuses.canceled], + testId: 'jobs-finished-tab', + showBadge: false, + }, + ]; + }, + showLoadingIcon() { + return this.loading && !this.allJobsCount; + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex align-items-lg-center"> + <gl-tabs content-class="gl-py-0" class="gl-w-full"> + <gl-tab + v-for="tab in tabs" + :key="tab.text" + :title-link-attributes="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + 'data-testid': tab.testId, + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + @click="$emit('fetchJobsByStatus', tab.scope)" + > + <template #title> + <span>{{ tab.text }}</span> + <gl-loading-icon v-if="showLoadingIcon && tab.showBadge" class="gl-ml-2" /> + + <gl-badge v-else-if="tab.showBadge" size="sm" class="gl-tab-counter-badge"> + {{ tab.count }} + </gl-badge> + </template> + </gl-tab> + </gl-tabs> + <div class="gl-flex-grow-1"></div> + <cancel-jobs v-if="showCancelAllJobsButton" :url="url" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/jobs_page/constants.js b/app/assets/javascripts/ci/jobs_page/constants.js new file mode 100644 index 00000000000..1b572e60c58 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/constants.js @@ -0,0 +1,76 @@ +import { s__, __ } from '~/locale'; + +/* Error constants */ +export const DEFAULT = 'default'; +export const RAW_TEXT_WARNING = s__( + 'Jobs|Raw text search is not currently supported for the jobs filtered search feature. Please use the available search tokens.', +); + +/* Job Status Constants */ +export const JOB_SCHEDULED = 'SCHEDULED'; +export const JOB_SUCCESS = 'SUCCESS'; + +/* Artifact file types */ +export const FILE_TYPE_ARCHIVE = 'ARCHIVE'; + +/* i18n */ +export const ACTIONS_DOWNLOAD_ARTIFACTS = __('Download artifacts'); +export const ACTIONS_START_NOW = s__('DelayedJobs|Start now'); +export const ACTIONS_UNSCHEDULE = s__('DelayedJobs|Unschedule'); +export const ACTIONS_PLAY = __('Play'); +export const ACTIONS_RETRY = __('Retry'); +export const ACTIONS_RUN_AGAIN = __('Run again'); + +export const CANCEL = __('Cancel'); +export const GENERIC_ERROR = __('An error occurred while making the request.'); +export const PLAY_JOB_CONFIRMATION_MESSAGE = s__( + `DelayedJobs|Are you sure you want to run %{job_name} immediately? This job will run automatically after its timer finishes.`, +); +export const RUN_JOB_NOW_HEADER_TITLE = s__('DelayedJobs|Run the delayed job now?'); + +/* Table constants */ +export const DEFAULT_FIELDS = [ + { + key: 'status', + label: __('Status'), + columnClass: 'gl-w-10p', + }, + { + key: 'job', + label: __('Job'), + columnClass: 'gl-w-20p', + }, + { + key: 'pipeline', + label: __('Pipeline'), + columnClass: 'gl-w-10p', + }, + { + key: 'stage', + label: __('Stage'), + columnClass: 'gl-w-10p', + }, + { + key: 'name', + label: __('Name'), + columnClass: 'gl-w-15p', + }, + { + key: 'duration', + label: __('Duration'), + columnClass: 'gl-w-15p', + }, + { + key: 'coverage', + label: __('Coverage'), + tdClass: 'gl-display-none! gl-lg-display-table-cell!', + columnClass: 'gl-w-10p', + }, + { + key: 'actions', + label: '', + columnClass: 'gl-w-10p', + }, +]; + +export const JOBS_TAB_FIELDS = DEFAULT_FIELDS.filter((field) => field.key !== 'pipeline'); diff --git a/app/assets/javascripts/ci/jobs_page/event_hub.js b/app/assets/javascripts/ci/jobs_page/event_hub.js new file mode 100644 index 00000000000..e31806ad199 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/event_hub.js @@ -0,0 +1,3 @@ +import createEventHub from '~/helpers/event_hub_factory'; + +export default createEventHub(); diff --git a/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js new file mode 100644 index 00000000000..5390c023da4 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/cache_config.js @@ -0,0 +1,60 @@ +import { isEqual } from 'lodash'; + +export default { + typePolicies: { + Project: { + fields: { + jobs: { + keyArgs: false, + }, + }, + }, + CiJobConnection: { + merge(existing = {}, incoming, { args = {} }) { + if (incoming.nodes) { + let nodes; + + const areNodesEqual = isEqual(existing.nodes, incoming.nodes); + const statuses = Array.isArray(args.statuses) ? [...args.statuses] : args.statuses; + const { pageInfo } = incoming; + + if (Object.keys(existing).length !== 0 && isEqual(existing?.statuses, args?.statuses)) { + if (areNodesEqual) { + if (incoming.pageInfo.hasNextPage) { + nodes = [...existing.nodes, ...incoming.nodes]; + } else { + nodes = [...incoming.nodes]; + } + } else { + if (!existing.pageInfo?.hasNextPage) { + nodes = [...incoming.nodes]; + + return { + nodes, + statuses, + pageInfo, + }; + } + + nodes = [...existing.nodes, ...incoming.nodes]; + } + } else { + nodes = [...incoming.nodes]; + } + + return { + nodes, + statuses, + pageInfo, + }; + } + + return { + nodes: existing.nodes, + pageInfo: existing.pageInfo, + statuses: args.statuses, + }; + }, + }, + }, +}; diff --git a/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql new file mode 100644 index 00000000000..3038216fdfc --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/fragments/job.fragment.graphql @@ -0,0 +1,7 @@ +fragment Job on CiJob { + id + detailedStatus { + id + detailsPath + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql new file mode 100644 index 00000000000..20935514d51 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_cancel.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation cancelJob($id: CiBuildID!) { + jobCancel(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql new file mode 100644 index 00000000000..c94b045ac40 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_play.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation playJob($id: CiBuildID!) { + jobPlay(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql new file mode 100644 index 00000000000..6e51f9a20fa --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_retry.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation retryJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql new file mode 100644 index 00000000000..8be8c42f3c3 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/mutations/job_unschedule.mutation.graphql @@ -0,0 +1,10 @@ +#import "../fragments/job.fragment.graphql" + +mutation unscheduleJob($id: CiBuildID!) { + jobUnschedule(input: { id: $id }) { + job { + ...Job + } + errors + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql new file mode 100644 index 00000000000..69719011079 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs.query.graphql @@ -0,0 +1,78 @@ +query getJobs($fullPath: ID!, $after: String, $first: Int = 30, $statuses: [CiJobStatus!]) { + project(fullPath: $fullPath) { + id + jobs(after: $after, first: $first, statuses: $statuses) { + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + nodes { + artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + pipeline { + id + path + user { + id + webPath + avatarUrl + } + } + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } +} diff --git a/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql new file mode 100644 index 00000000000..a4e02ae721a --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/graphql/queries/get_jobs_count.query.graphql @@ -0,0 +1,8 @@ +query getJobsCount($fullPath: ID!, $statuses: [CiJobStatus!]) { + project(fullPath: $fullPath) { + id + jobs(statuses: $statuses) { + count + } + } +} diff --git a/app/assets/javascripts/ci/jobs_page/index.js b/app/assets/javascripts/ci/jobs_page/index.js new file mode 100644 index 00000000000..7e99157289b --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/index.js @@ -0,0 +1,50 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import JobsTableApp from '~/ci/jobs_page/jobs_page_app.vue'; +import createDefaultClient from '~/lib/graphql'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import cacheConfig from './graphql/cache_config'; + +Vue.use(VueApollo); +Vue.use(GlToast); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + cacheConfig, + }, + ), +}); + +export default (containerId = 'js-jobs-table') => { + const containerEl = document.getElementById(containerId); + + if (!containerEl) { + return false; + } + + const { + fullPath, + jobStatuses, + pipelineEditorPath, + emptyStateSvgPath, + admin, + } = containerEl.dataset; + + return new Vue({ + el: containerEl, + apolloProvider, + provide: { + emptyStateSvgPath, + fullPath, + pipelineEditorPath, + jobStatuses: JSON.parse(jobStatuses), + admin: parseBoolean(admin), + }, + render(createElement) { + return createElement(JobsTableApp); + }, + }); +}; diff --git a/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue new file mode 100644 index 00000000000..03e0f2dadc8 --- /dev/null +++ b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue @@ -0,0 +1,238 @@ +<script> +import { GlAlert, GlIntersectionObserver, GlLoadingIcon } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; +import { setUrlParams, updateHistory, queryToObject } from '~/lib/utils/url_utility'; +import JobsSkeletonLoader from '~/ci/admin/jobs_table/components/jobs_skeleton_loader.vue'; +import JobsFilteredSearch from '~/ci/common/private/jobs_filtered_search/app.vue'; +import { validateQueryString } from '~/ci/common/private/jobs_filtered_search/utils'; +import GetJobs from './graphql/queries/get_jobs.query.graphql'; +import GetJobsCount from './graphql/queries/get_jobs_count.query.graphql'; +import JobsTable from './components/jobs_table.vue'; +import JobsTableEmptyState from './components/jobs_table_empty_state.vue'; +import JobsTableTabs from './components/jobs_table_tabs.vue'; +import { RAW_TEXT_WARNING } from './constants'; + +export default { + i18n: { + jobsFetchErrorMsg: __('There was an error fetching the jobs for your project.'), + jobsCountErrorMsg: __('There was an error fetching the number of jobs for your project.'), + loadingAriaLabel: __('Loading'), + }, + filterSearchBoxStyles: + 'gl-my-0 gl-p-5 gl-bg-gray-10 gl-text-gray-900 gl-border-b gl-border-gray-100', + components: { + GlAlert, + JobsFilteredSearch, + JobsTable, + JobsTableEmptyState, + JobsTableTabs, + GlIntersectionObserver, + GlLoadingIcon, + JobsSkeletonLoader, + }, + inject: { + fullPath: { + default: '', + }, + }, + apollo: { + jobs: { + query: GetJobs, + variables() { + return { + fullPath: this.fullPath, + ...this.validatedQueryString, + }; + }, + update(data) { + const { jobs: { nodes: list = [], pageInfo = {} } = {} } = data.project || {}; + return { + list, + pageInfo, + }; + }, + error() { + this.error = this.$options.i18n.jobsFetchErrorMsg; + }, + }, + jobsCount: { + query: GetJobsCount, + context: { + isSingleRequest: true, + }, + variables() { + return { + fullPath: this.fullPath, + ...this.validatedQueryString, + }; + }, + update({ project }) { + return project?.jobs?.count || 0; + }, + error() { + this.error = this.$options.i18n.jobsCountErrorMsg; + }, + }, + }, + data() { + return { + jobs: { + list: [], + }, + error: '', + scope: null, + infiniteScrollingTriggered: false, + filterSearchTriggered: false, + jobsCount: null, + count: 0, + }; + }, + computed: { + loading() { + return this.$apollo.queries.jobs.loading; + }, + // Show when on All tab with no jobs + // Show only when not loading and filtered search has not been triggered + // So we don't show empty state when results are empty on a filtered search + showEmptyState() { + return ( + this.jobs.list.length === 0 && !this.scope && !this.loading && !this.filterSearchTriggered + ); + }, + hasNextPage() { + return this.jobs?.pageInfo?.hasNextPage; + }, + showLoadingSpinner() { + return this.loading && this.infiniteScrollingTriggered; + }, + showSkeletonLoader() { + return this.loading && !this.showLoadingSpinner; + }, + showFilteredSearch() { + return !this.scope; + }, + validatedQueryString() { + const queryStringObject = queryToObject(window.location.search); + + return validateQueryString(queryStringObject); + }, + }, + watch: { + // this watcher ensures that the count on the all tab + // is not updated when switching to the finished tab + jobsCount(newCount, oldCount) { + if (this.scope) { + this.count = oldCount; + } else { + this.count = newCount; + } + }, + }, + methods: { + updateHistoryAndFetchCount(status = null) { + this.$apollo.queries.jobsCount.refetch({ statuses: status }); + + updateHistory({ + url: setUrlParams({ statuses: status }, window.location.href, true), + }); + }, + fetchJobsByStatus(scope) { + this.infiniteScrollingTriggered = false; + + if (this.scope === scope) return; + + this.scope = scope; + + if (!this.scope) this.updateHistoryAndFetchCount(); + + this.$apollo.queries.jobs.refetch({ statuses: scope }); + }, + filterJobsBySearch(filters) { + this.infiniteScrollingTriggered = false; + this.filterSearchTriggered = true; + + // all filters have been cleared reset query param + // and refetch jobs/count with defaults + if (!filters.length) { + this.updateHistoryAndFetchCount(); + this.$apollo.queries.jobs.refetch({ statuses: null }); + + return; + } + + // Eventually there will be more tokens available + // this code is written to scale for those tokens + filters.forEach((filter) => { + // Raw text input in filtered search does not have a type + // when a user enters raw text we alert them that it is + // not supported and we do not make an additional API call + if (!filter.type) { + createAlert({ + message: RAW_TEXT_WARNING, + type: 'warning', + }); + } + + if (filter.type === 'status') { + this.updateHistoryAndFetchCount(filter.value.data); + this.$apollo.queries.jobs.refetch({ statuses: filter.value.data }); + } + }); + }, + fetchMoreJobs() { + if (!this.loading) { + this.infiniteScrollingTriggered = true; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + fullPath: this.fullPath, + after: this.jobs?.pageInfo?.endCursor, + }, + }); + } + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="error" + class="gl-mt-2" + variant="danger" + data-testid="jobs-table-error-alert" + dismissible + @dismiss="error = ''" + > + {{ error }} + </gl-alert> + + <jobs-table-tabs + :all-jobs-count="count" + :loading="loading" + @fetchJobsByStatus="fetchJobsByStatus" + /> + <div v-if="showFilteredSearch" :class="$options.filterSearchBoxStyles"> + <jobs-filtered-search + :query-string="validatedQueryString" + @filterJobsBySearch="filterJobsBySearch" + /> + </div> + + <jobs-skeleton-loader v-if="showSkeletonLoader" class="gl-mt-5" /> + + <jobs-table-empty-state v-else-if="showEmptyState" /> + + <jobs-table v-else :jobs="jobs.list" class="gl-table-no-top-border" /> + + <gl-intersection-observer v-if="hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon + v-if="showLoadingSpinner" + size="lg" + :aria-label="$options.i18n.loadingAriaLabel" + /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue b/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue new file mode 100644 index 00000000000..ee911d716e4 --- /dev/null +++ b/app/assets/javascripts/ci/merge_requests/components/pipelines_table_wrapper.vue @@ -0,0 +1,60 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils'; +import { graphqlEtagMergeRequestPipelines } from '~/ci/pipeline_details/utils'; +import getMergeRequestPipelines from '../graphql/queries/get_merge_request_pipelines.query.graphql'; + +export default { + components: { + GlLoadingIcon, + }, + inject: ['graphqlPath', 'mergeRequestId', 'targetProjectFullPath'], + data() { + return { + pipelines: [], + }; + }, + apollo: { + pipelines: { + query: getMergeRequestPipelines, + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + pollInterval: 10000, + variables() { + return { + fullPath: this.targetProjectFullPath, + mergeRequestIid: String(this.mergeRequestId), + }; + }, + update(data) { + return data?.project?.mergeRequest?.pipelines?.nodes || []; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + computed: { + graphqlResourceEtag() { + return graphqlEtagMergeRequestPipelines(this.graphqlPath, this.mergeRequestId); + }, + isLoading() { + return this.$apollo.queries.pipelines.loading; + }, + }, + i18n: { + fetchError: __("There was an error fetching this merge request's pipelines."), + }, +}; +</script> +<template> + <div class="gl-mt-3"> + <gl-loading-icon v-if="isLoading" size="lg" /> + <ul v-else> + <li v-for="pipeline in pipelines" :key="pipeline.id">{{ pipeline.path }}</li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql new file mode 100644 index 00000000000..022d461dbec --- /dev/null +++ b/app/assets/javascripts/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql @@ -0,0 +1,5 @@ +mutation retryMrFailedJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql b/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql new file mode 100644 index 00000000000..8c235032e6c --- /dev/null +++ b/app/assets/javascripts/ci/merge_requests/graphql/queries/get_merge_request_pipelines.query.graphql @@ -0,0 +1,16 @@ +query getMergeRequestPipelines($mergeRequestIid: String!, $fullPath: ID!) { + project(fullPath: $fullPath) { + id + mergeRequest(iid: $mergeRequestIid) { + id + pipelines { + count + nodes { + id + iid + path + } + } + } + } +} diff --git a/app/assets/javascripts/ci/mixins/delayed_job_mixin.js b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js new file mode 100644 index 00000000000..7b17dc7f693 --- /dev/null +++ b/app/assets/javascripts/ci/mixins/delayed_job_mixin.js @@ -0,0 +1,53 @@ +import { calculateRemainingMilliseconds, formatTime } from '~/lib/utils/datetime_utility'; + +export default { + data() { + return { + remainingTime: formatTime(0), + remainingTimeIntervalId: null, + }; + }, + + mounted() { + this.startRemainingTimeInterval(); + }, + + beforeDestroy() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + }, + + computed: { + isDelayedJob() { + return this.job?.scheduled || this.job?.scheduledAt; + }, + scheduledTime() { + return this.job.scheduled_at || this.job.scheduledAt; + }, + }, + + watch: { + isDelayedJob() { + this.startRemainingTimeInterval(); + }, + }, + + methods: { + startRemainingTimeInterval() { + if (this.remainingTimeIntervalId) { + clearInterval(this.remainingTimeIntervalId); + } + + if (this.isDelayedJob) { + this.updateRemainingTime(); + this.remainingTimeIntervalId = setInterval(() => this.updateRemainingTime(), 1000); + } + }, + + updateRemainingTime() { + const remainingMilliseconds = calculateRemainingMilliseconds(this.scheduledTime); + this.remainingTime = formatTime(remainingMilliseconds); + }, + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js new file mode 100644 index 00000000000..bf312e66144 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/constants.js @@ -0,0 +1,77 @@ +import { __, s__ } from '~/locale'; + +export const CANCEL_REQUEST = 'CANCEL_REQUEST'; +export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; +export const SCHEDULE_ORIGIN = 'schedule'; +export const NEEDS_PROPERTY = 'needs'; +export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; + +export const TestStatus = { + FAILED: 'failed', + SKIPPED: 'skipped', + SUCCESS: 'success', + ERROR: 'error', + UNKNOWN: 'unknown', +}; + +/* Error constants shared across graphs */ +export const DEFAULT = 'default'; +export const DELETE_FAILURE = 'delete_pipeline_failure'; +export const DRAW_FAILURE = 'draw_failure'; +export const LOAD_FAILURE = 'load_failure'; +export const PARSE_FAILURE = 'parse_failure'; +export const POST_FAILURE = 'post_failure'; +export const UNSUPPORTED_DATA = 'unsupported_data'; + +export const CHILD_VIEW = 'child'; + +// Pipeline tabs + +export const pipelineTabName = 'graph'; +export const needsTabName = 'dag'; +export const jobsTabName = 'builds'; +export const failedJobsTabName = 'failures'; +export const testReportTabName = 'test_report'; +export const securityTabName = 'security'; +export const licensesTabName = 'licenses'; +export const codeQualityTabName = 'codequality_report'; + +export const validPipelineTabNames = [ + needsTabName, + jobsTabName, + failedJobsTabName, + testReportTabName, + securityTabName, + licensesTabName, + codeQualityTabName, +]; + +export const TOAST_MESSAGE = s__('Pipeline|Creating pipeline.'); + +export const DEFAULT_FIELDS = [ + { + key: 'name', + label: __('Name'), + columnClass: 'gl-w-20p', + }, + { + key: 'stage', + label: __('Stage'), + columnClass: 'gl-w-20p', + }, + { + key: 'failureMessage', + label: __('Failure'), + columnClass: 'gl-w-40p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right', + columnClass: 'gl-w-20p', + }, +]; + +// Pipeline Mini Graph + +export const PIPELINE_MINI_GRAPH_POLL_INTERVAL = 5000; diff --git a/app/assets/javascripts/ci/pipeline_details/dag/components/dag_annotations.vue b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_annotations.vue new file mode 100644 index 00000000000..a1500166cdc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_annotations.vue @@ -0,0 +1,73 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export default { + name: 'DagAnnotations', + components: { + GlButton, + }, + props: { + annotations: { + type: Object, + required: true, + }, + }, + data() { + return { + showList: true, + }; + }, + computed: { + linkText() { + return this.showList ? __('Hide list') : __('Show list'); + }, + shouldShowLink() { + return Object.keys(this.annotations).length > 1; + }, + wrapperClasses() { + return [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-fixed', + 'gl-right-1', + 'gl-top-66vh', + 'gl-w-max-content', + 'gl-px-5', + 'gl-py-4', + 'gl-rounded-base', + 'gl-bg-white', + ].join(' '); + }, + }, + methods: { + toggleList() { + this.showList = !this.showList; + }, + }, +}; +</script> +<template> + <div :class="wrapperClasses"> + <div v-if="showList"> + <div + v-for="note in annotations" + :key="note.uid" + class="gl-display-flex gl-align-items-center" + > + <div + data-testid="dag-color-block" + class="gl-w-6 gl-h-5" + :style="{ + background: `linear-gradient(0.25turn, ${note.source.color} 40%, ${note.target.color} 60%)`, + }" + ></div> + <div data-testid="dag-note-text" class="gl-px-2 gl-font-base gl-align-items-center"> + {{ note.source.name }} → {{ note.target.name }} + </div> + </div> + </div> + + <gl-button v-if="shouldShowLink" variant="link" @click="toggleList">{{ linkText }}</gl-button> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/dag/components/dag_graph.vue b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_graph.vue new file mode 100644 index 00000000000..6e975d55a7f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/components/dag_graph.vue @@ -0,0 +1,329 @@ +<script> +import * as d3 from 'd3'; +import { uniqueId } from 'lodash'; +import { getMaxNodes, removeOrphanNodes } from '~/ci/pipeline_details/utils/parsing_utils'; +import { PARSE_FAILURE } from '../../constants'; +import { LINK_SELECTOR, NODE_SELECTOR, ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from '../constants'; +import { calculateClip, createLinkPath, createSankey, labelPosition } from '../utils/drawing_utils'; +import { + currentIsLive, + getLiveLinksAsDict, + highlightLinks, + restoreLinks, + toggleLinkHighlight, + togglePathHighlights, +} from '../utils/interactions'; + +export default { + viewOptions: { + baseHeight: 300, + baseWidth: 1000, + minNodeHeight: 60, + nodeWidth: 16, + nodePadding: 25, + paddingForLabels: 100, + labelMargin: 8, + + baseOpacity: 0.8, + containerClasses: ['dag-graph-container', 'gl-display-flex', 'gl-flex-direction-column'].join( + ' ', + ), + hoverFadeClasses: [ + 'gl-cursor-pointer', + 'gl-transition-duration-slow', + 'gl-transition-timing-function-ease', + ].join(' '), + }, + gitLabColorRotation: [ + '#e17223', + '#83ab4a', + '#5772ff', + '#b24800', + '#25d2d2', + '#006887', + '#487900', + '#d84280', + '#3547de', + '#6f3500', + '#006887', + '#275600', + '#b31756', + ], + props: { + graphData: { + type: Object, + required: true, + }, + }, + data() { + return { + color: () => {}, + height: 0, + width: 0, + }; + }, + mounted() { + let countedAndTransformed; + + try { + countedAndTransformed = this.transformData(this.graphData); + } catch { + this.$emit('on-failure', PARSE_FAILURE); + return; + } + + this.drawGraph(countedAndTransformed); + }, + methods: { + addSvg() { + return d3 + .select('.dag-graph-container') + .append('svg') + .attr('viewBox', [0, 0, this.width, this.height]) + .attr('width', this.width) + .attr('height', this.height); + }, + + appendLinks(link) { + return ( + link + .append('path') + .attr('d', (d, i) => createLinkPath(d, i, this.$options.viewOptions.nodeWidth)) + .attr('stroke', ({ gradId }) => `url(#${gradId})`) + .style('stroke-linejoin', 'round') + // minus two to account for the rounded nodes + .attr('stroke-width', ({ width }) => Math.max(1, width - 2)) + .attr('clip-path', ({ clipId }) => `url(#${clipId})`) + ); + }, + + appendLinkInteractions(link) { + const { baseOpacity } = this.$options.viewOptions; + return link + .on('mouseover', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: ADD_NOTE, data: d }); + highlightLinks(d, idx, collection); + }) + .on('mouseout', (d, idx, collection) => { + if (currentIsLive(idx, collection)) { + return; + } + this.$emit('update-annotation', { type: REMOVE_NOTE, data: d }); + restoreLinks(baseOpacity); + }) + .on('click', (d, idx, collection) => { + toggleLinkHighlight(baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); + }, + + appendNodeInteractions(node) { + return node.on('click', (d, idx, collection) => { + togglePathHighlights(this.$options.viewOptions.baseOpacity, d, idx, collection); + this.$emit('update-annotation', { type: REPLACE_NOTES, data: getLiveLinksAsDict() }); + }); + }, + + appendLabelAsForeignObject(d, i, n) { + const currentNode = n[i]; + const { height, wrapperWidth, width, x, y, textAlign } = labelPosition(d, { + ...this.$options.viewOptions, + width: this.width, + }); + + const labelClasses = [ + 'gl-display-flex', + 'gl-pointer-events-none', + 'gl-flex-direction-column', + 'gl-justify-content-center', + 'gl-overflow-wrap-break', + ].join(' '); + + return ( + d3 + .select(currentNode) + .attr('requiredFeatures', 'http://www.w3.org/TR/SVG11/feature#Extensibility') + .attr('height', height) + /* + items with a 'max-content' width will have a wrapperWidth for the foreignObject + */ + .attr('width', wrapperWidth || width) + .attr('x', x) + .attr('y', y) + .classed('gl-overflow-visible', true) + .append('xhtml:div') + .classed(labelClasses, true) + .style('height', height) + .style('width', width) + .style('text-align', textAlign) + .text(({ name }) => name) + ); + }, + + createAndAssignId(datum, field, modifier = '') { + const id = uniqueId(modifier); + /* eslint-disable-next-line no-param-reassign */ + datum[field] = id; + return id; + }, + + createClip(link) { + return link + .append('clipPath') + .attr('id', (d) => { + return this.createAndAssignId(d, 'clipId', 'dag-clip'); + }) + .append('path') + .attr('d', calculateClip); + }, + + createGradient(link) { + const gradient = link + .append('linearGradient') + .attr('id', (d) => { + return this.createAndAssignId(d, 'gradId', 'dag-grad'); + }) + .attr('gradientUnits', 'userSpaceOnUse') + .attr('x1', ({ source }) => source.x1) + .attr('x2', ({ target }) => target.x0); + + gradient + .append('stop') + .attr('offset', '0%') + .attr('stop-color', ({ source }) => this.color(source)); + + gradient + .append('stop') + .attr('offset', '100%') + .attr('stop-color', ({ target }) => this.color(target)); + }, + + createLinks(svg, linksData) { + const links = this.generateLinks(svg, linksData); + this.createGradient(links); + this.createClip(links); + this.appendLinks(links); + this.appendLinkInteractions(links); + }, + + createNodes(svg, nodeData) { + const nodes = this.generateNodes(svg, nodeData); + this.labelNodes(svg, nodeData); + this.appendNodeInteractions(nodes); + }, + + drawGraph({ maxNodesPerLayer, linksAndNodes }) { + const { + baseWidth, + baseHeight, + minNodeHeight, + nodeWidth, + nodePadding, + paddingForLabels, + } = this.$options.viewOptions; + + this.width = baseWidth; + this.height = baseHeight + maxNodesPerLayer * minNodeHeight; + this.color = this.initColors(); + + const { links, nodes } = createSankey({ + width: this.width, + height: this.height, + nodeWidth, + nodePadding, + paddingForLabels, + })(linksAndNodes); + + const svg = this.addSvg(); + this.createLinks(svg, links); + this.createNodes(svg, nodes); + }, + + generateLinks(svg, linksData) { + return svg + .append('g') + .attr('fill', 'none') + .attr('stroke-opacity', this.$options.viewOptions.baseOpacity) + .selectAll(`.${LINK_SELECTOR}`) + .data(linksData) + .enter() + .append('g') + .attr('id', (d) => { + return this.createAndAssignId(d, 'uid', LINK_SELECTOR); + }) + .classed( + `${LINK_SELECTOR} gl-transition-property-stroke-opacity ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ); + }, + + generateNodes(svg, nodeData) { + const { nodeWidth } = this.$options.viewOptions; + + return svg + .append('g') + .selectAll(`.${NODE_SELECTOR}`) + .data(nodeData) + .enter() + .append('line') + .classed( + `${NODE_SELECTOR} gl-transition-property-stroke ${this.$options.viewOptions.hoverFadeClasses}`, + true, + ) + .attr('id', (d) => { + return this.createAndAssignId(d, 'uid', NODE_SELECTOR); + }) + .attr('stroke', (d) => { + const color = this.color(d); + /* eslint-disable-next-line no-param-reassign */ + d.color = color; + return color; + }) + .attr('stroke-width', nodeWidth) + .attr('stroke-linecap', 'round') + .attr('x1', (d) => Math.floor((d.x1 + d.x0) / 2)) + .attr('x2', (d) => Math.floor((d.x1 + d.x0) / 2)) + .attr('y1', (d) => d.y0 + 4) + .attr('y2', (d) => d.y1 - 4); + }, + + initColors() { + const colorFn = d3.scaleOrdinal(this.$options.gitLabColorRotation); + return ({ name }) => colorFn(name); + }, + + labelNodes(svg, nodeData) { + return svg + .append('g') + .classed('gl-font-sm', true) + .selectAll('text') + .data(nodeData) + .enter() + .append('foreignObject') + .each(this.appendLabelAsForeignObject); + }, + + transformData(parsed) { + const baseLayout = createSankey()(parsed); + const cleanedNodes = removeOrphanNodes(baseLayout.nodes); + const maxNodesPerLayer = getMaxNodes(cleanedNodes); + + return { + maxNodesPerLayer, + linksAndNodes: { + links: parsed.links, + nodes: cleanedNodes, + }, + }; + }, + }, +}; +</script> +<template> + <div :class="$options.viewOptions.containerClasses" data-testid="dag-graph-container"> + <!-- graph goes here --> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/dag/constants.js b/app/assets/javascripts/ci/pipeline_details/dag/constants.js new file mode 100644 index 00000000000..cd89055737f --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/constants.js @@ -0,0 +1,9 @@ +/* Interaction handles */ +export const IS_HIGHLIGHTED = 'dag-highlighted'; +export const LINK_SELECTOR = 'dag-link'; +export const NODE_SELECTOR = 'dag-node'; + +/* Annotation types */ +export const ADD_NOTE = 'add'; +export const REMOVE_NOTE = 'remove'; +export const REPLACE_NOTES = 'replace'; diff --git a/app/assets/javascripts/ci/pipeline_details/dag/dag.vue b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue new file mode 100644 index 00000000000..5415340c956 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/dag.vue @@ -0,0 +1,254 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { GlAlert, GlButton, GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { fetchPolicies } from '~/lib/graphql'; +import { __ } from '~/locale'; +import { + DEFAULT, + PARSE_FAILURE, + LOAD_FAILURE, + UNSUPPORTED_DATA, +} from '~/ci/pipeline_details/constants'; +import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; +import getDagVisData from './graphql/queries/get_dag_vis_data.query.graphql'; +import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES } from './constants'; +import DagAnnotations from './components/dag_annotations.vue'; +import DagGraph from './components/dag_graph.vue'; + +export default { + // eslint-disable-next-line @gitlab/require-i18n-strings + name: 'Dag', + components: { + DagAnnotations, + DagGraph, + GlAlert, + GlButton, + GlEmptyState, + GlLink, + GlSprintf, + }, + inject: { + aboutDagDocPath: { + default: null, + }, + dagDocPath: { + default: null, + }, + emptyDagSvgPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + pipelineProjectPath: { + default: '', + }, + }, + apollo: { + graphData: { + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + query: getDagVisData, + variables() { + return { + projectPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + if (!data?.project?.pipeline) { + return this.graphData; + } + + const { + stages: { nodes: stages }, + } = data.project.pipeline; + + const unwrappedGroups = stages + .map(({ name, groups: { nodes: groups } }) => { + return groups.map((group) => { + return { category: name, ...group }; + }); + }) + .flat(2); + + const nodes = unwrappedGroups.map((group) => { + const jobs = group.jobs.nodes.map(({ name, needs }) => { + return { name, needs: needs.nodes.map((need) => need.name) }; + }); + + return { ...group, jobs }; + }); + + return nodes; + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + }, + }, + data() { + return { + annotationsMap: {}, + failureType: null, + graphData: null, + showFailureAlert: false, + hasNoDependentJobs: false, + }; + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for this graph.'), + [PARSE_FAILURE]: __('There was an error parsing the data for this graph.'), + [UNSUPPORTED_DATA]: __('DAG visualization requires at least 3 dependent jobs.'), + [DEFAULT]: __('An unknown error occurred while loading this graph.'), + }, + emptyStateTexts: { + title: __('Speed up your pipelines with Needs relationships'), + firstDescription: __( + 'Using the %{codeStart}needs%{codeEnd} keyword makes jobs run before their stage is reached. Jobs run as soon as their %{codeStart}needs%{codeEnd} relationships are met, which speeds up your pipelines.', + ), + secondDescription: __( + "If you add %{codeStart}needs%{codeEnd} to jobs in your pipeline you'll be able to view the %{codeStart}needs%{codeEnd} relationships between jobs in this tab as a %{linkStart}Directed Acyclic Graph (DAG)%{linkEnd}.", + ), + button: __('Learn more about Needs relationships'), + }, + computed: { + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case PARSE_FAILURE: + return { + text: this.$options.errorTexts[PARSE_FAILURE], + variant: 'danger', + }; + case UNSUPPORTED_DATA: + return { + text: this.$options.errorTexts[UNSUPPORTED_DATA], + variant: 'info', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, + processedData() { + return this.processGraphData(this.graphData); + }, + shouldDisplayAnnotations() { + return !isEmpty(this.annotationsMap); + }, + shouldDisplayGraph() { + return Boolean(!this.showFailureAlert && !this.hasNoDependentJobs && this.graphData); + }, + }, + methods: { + addAnnotationToMap({ uid, source, target }) { + this.$set(this.annotationsMap, uid, { source, target }); + }, + processGraphData(data) { + let parsed; + + try { + parsed = parseData(data); + } catch { + this.reportFailure(PARSE_FAILURE); + return {}; + } + + if (parsed.links.length === 1) { + this.reportFailure(UNSUPPORTED_DATA); + return {}; + } + + // If there are no links, we don't report failure + // as it simply means the user does not use job dependencies + if (parsed.links.length === 0) { + this.hasNoDependentJobs = true; + return {}; + } + + return parsed; + }, + hideAlert() { + this.showFailureAlert = false; + }, + removeAnnotationFromMap({ uid }) { + this.$delete(this.annotationsMap, uid); + }, + reportFailure(type) { + this.showFailureAlert = true; + this.failureType = type; + }, + updateAnnotation({ type, data }) { + switch (type) { + case ADD_NOTE: + this.addAnnotationToMap(data); + break; + case REMOVE_NOTE: + this.removeAnnotationFromMap(data); + break; + case REPLACE_NOTES: + this.annotationsMap = data; + break; + default: + break; + } + }, + }, +}; +</script> +<template> + <div> + <gl-alert v-if="showFailureAlert" :variant="failure.variant" @dismiss="hideAlert"> + {{ failure.text }} + </gl-alert> + + <div class="gl-relative"> + <dag-annotations v-if="shouldDisplayAnnotations" :annotations="annotationsMap" /> + <dag-graph + v-if="shouldDisplayGraph" + :graph-data="processedData" + @onFailure="reportFailure" + @update-annotation="updateAnnotation" + /> + <gl-empty-state + v-else-if="hasNoDependentJobs" + :svg-path="emptyDagSvgPath" + :title="$options.emptyStateTexts.title" + > + <template #description> + <div class="gl-text-left"> + <p> + <gl-sprintf :message="$options.emptyStateTexts.firstDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + <p> + <gl-sprintf :message="$options.emptyStateTexts.secondDescription"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link :href="aboutDagDocPath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </template> + <template v-if="dagDocPath" #actions> + <gl-button :href="dagDocPath" target="_blank" variant="confirm"> + {{ $options.emptyStateTexts.button }} + </gl-button> + </template> + </gl-empty-state> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/dag/graphql/queries/get_dag_vis_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/dag/graphql/queries/get_dag_vis_data.query.graphql new file mode 100644 index 00000000000..2a0b13dd0cc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/graphql/queries/get_dag_vis_data.query.graphql @@ -0,0 +1,33 @@ +query getDagVisData($projectPath: ID!, $iid: ID!) { + project(fullPath: $projectPath) { + id + pipeline(iid: $iid) { + id + stages { + nodes { + id + name + groups { + nodes { + id + name + size + jobs { + nodes { + id + name + needs { + nodes { + id + name + } + } + } + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/dag/utils/drawing_utils.js b/app/assets/javascripts/ci/pipeline_details/dag/utils/drawing_utils.js new file mode 100644 index 00000000000..3cd09d57ffb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/utils/drawing_utils.js @@ -0,0 +1,134 @@ +import * as d3 from 'd3'; +import { sankey, sankeyLeft } from 'd3-sankey'; + +export const calculateClip = ({ y0, y1, source, target, width }) => { + /* + Because large link values can overrun their box, we create a clip path + to trim off the excess in charts that have few nodes per column and are + therefore tall. + + The box is created by + M: moving to outside midpoint of the source node + V: drawing a vertical line to maximum of the bottom link edge or + the lowest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line to the outside edge of the destination node + V: drawing a vertical line back up to the minimum of the top link edge or + the highest edge of the node (can be d.y0 or d.y1 depending on the link's path) + H: drawing a horizontal line back to the outside edge of the source node + Z: closing the path, back to the start point + */ + + const bottomLinkEdge = Math.max(y1, y0) + width / 2; + const topLinkEdge = Math.min(y0, y1) - width / 2; + + /* eslint-disable @gitlab/require-i18n-strings */ + return ` + M${source.x0}, ${y1} + V${Math.max(bottomLinkEdge, y0, y1)} + H${target.x1} + V${Math.min(topLinkEdge, y0, y1)} + H${source.x0} + Z + `; + /* eslint-enable @gitlab/require-i18n-strings */ +}; + +export const createLinkPath = ({ y0, y1, source, target, width }, idx, nodeWidth) => { + /* + Creates a series of staggered midpoints for the link paths, so they + don't run along one channel and can be distinguished. + + First, get a point staggered by index and link width, modulated by the link box + to find a point roughly between the nodes. + + Then offset it by nodeWidth, so it doesn't run under any nodes at the left. + + Determine where it would overlap at the right. + + Finally, select the leftmost of these options: + - offset from the source node based on index + fudge; + - a fuzzy offset from the right node, using Math.random adds a little blur + - a hard offset from the end node, if random pushes it over + + Then draw a line from the start node to the bottom-most point of the midline + up to the topmost point in that line and then to the middle of the end node + */ + + const xValRaw = source.x1 + (((idx + 1) * width) % (target.x1 - source.x0)); + const xValMin = xValRaw + nodeWidth; + const overlapPoint = source.x1 + (target.x0 - source.x1); + const xValMax = overlapPoint - nodeWidth * 1.4; + + const midPointX = Math.min(xValMin, target.x0 - nodeWidth * 4 * Math.random(), xValMax); + + return d3.line()([ + [(source.x0 + source.x1) / 2, y0], + [midPointX, y0], + [midPointX, y1], + [(target.x0 + target.x1) / 2, y1], + ]); +}; + +/* + createSankey calls the d3 layout to generate the relationships and positioning + values for the nodes and links in the graph. + */ + +export const createSankey = ({ + width = 10, + height = 10, + nodeWidth = 10, + nodePadding = 10, + paddingForLabels = 1, +} = {}) => { + const sankeyGenerator = sankey() + .nodeId(({ name }) => name) + .nodeAlign(sankeyLeft) + .nodeWidth(nodeWidth) + .nodePadding(nodePadding) + .extent([ + [paddingForLabels, paddingForLabels], + [width - paddingForLabels, height - paddingForLabels], + ]); + return ({ nodes, links }) => + sankeyGenerator({ + nodes: nodes.map((d) => ({ ...d })), + links: links.map((d) => ({ ...d })), + }); +}; + +export const labelPosition = ({ x0, x1, y0, y1 }, viewOptions) => { + const { paddingForLabels, labelMargin, nodePadding, width } = viewOptions; + + const firstCol = x0 <= paddingForLabels; + const lastCol = x1 >= width - paddingForLabels; + + if (firstCol) { + return { + x: 0 + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'right', + }; + } + + if (lastCol) { + return { + x: width - paddingForLabels + labelMargin, + y: y0, + height: `${y1 - y0}px`, + width: paddingForLabels - 2 * labelMargin, + textAlign: 'left', + }; + } + + return { + x: (x1 + x0) / 2, + y: y0 - nodePadding, + height: `${nodePadding}px`, + width: 'max-content', + wrapperWidth: paddingForLabels - 2 * labelMargin, + textAlign: x0 < width / 2 ? 'left' : 'right', + }; +}; diff --git a/app/assets/javascripts/ci/pipeline_details/dag/utils/interactions.js b/app/assets/javascripts/ci/pipeline_details/dag/utils/interactions.js new file mode 100644 index 00000000000..d2b7b7f9069 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/dag/utils/interactions.js @@ -0,0 +1,154 @@ +import * as d3 from 'd3'; +import { LINK_SELECTOR, NODE_SELECTOR, IS_HIGHLIGHTED } from '../constants'; + +export const highlightIn = 1; +export const highlightOut = 0.2; + +const getCurrent = (idx, collection) => d3.select(collection[idx]); +const getLiveLinks = () => d3.selectAll(`.${LINK_SELECTOR}.${IS_HIGHLIGHTED}`); +const getOtherLinks = () => d3.selectAll(`.${LINK_SELECTOR}:not(.${IS_HIGHLIGHTED})`); +const getNodesNotLive = () => d3.selectAll(`.${NODE_SELECTOR}:not(.${IS_HIGHLIGHTED})`); + +export const getLiveLinksAsDict = () => { + return Object.fromEntries( + getLiveLinks() + .data() + .map((d) => [d.uid, d]), + ); +}; +export const currentIsLive = (idx, collection) => + getCurrent(idx, collection).classed(IS_HIGHLIGHTED); + +const backgroundLinks = (selection) => selection.style('stroke-opacity', highlightOut); +const backgroundNodes = (selection) => selection.attr('stroke', '#f2f2f2'); +const foregroundLinks = (selection) => selection.style('stroke-opacity', highlightIn); +const foregroundNodes = (selection) => selection.attr('stroke', (d) => d.color); +const renewLinks = (selection, baseOpacity) => selection.style('stroke-opacity', baseOpacity); +const renewNodes = (selection) => selection.attr('stroke', (d) => d.color); + +export const getAllLinkAncestors = (node) => { + if (node.targetLinks) { + return node.targetLinks.flatMap((n) => { + return [n, ...getAllLinkAncestors(n.source)]; + }); + } + + return []; +}; + +const getAllNodeAncestors = (node) => { + let allNodes = []; + + if (node.targetLinks) { + allNodes = node.targetLinks.flatMap((n) => { + return getAllNodeAncestors(n.source); + }); + } + + return [...allNodes, node.uid]; +}; + +export const highlightLinks = (d, idx, collection) => { + const currentLink = getCurrent(idx, collection); + const currentSourceNode = d3.select(`#${d.source.uid}`); + const currentTargetNode = d3.select(`#${d.target.uid}`); + + /* Higlight selected link, de-emphasize others */ + backgroundLinks(getOtherLinks()); + foregroundLinks(currentLink); + + /* Do the same to related nodes */ + backgroundNodes(getNodesNotLive()); + foregroundNodes(currentSourceNode); + foregroundNodes(currentTargetNode); +}; + +const highlightPath = (parentLinks, parentNodes) => { + /* de-emphasize everything else */ + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); + + /* highlight correct links */ + parentLinks.forEach(({ uid }) => { + foregroundLinks(d3.select(`#${uid}`)).classed(IS_HIGHLIGHTED, true); + }); + + /* highlight correct nodes */ + parentNodes.forEach((id) => { + foregroundNodes(d3.select(`#${id}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + +const restoreNodes = () => { + /* + When paths are unclicked, they can take down nodes that + are still in use for other paths. This checks the live paths and + rehighlights their nodes. + */ + + getLiveLinks().each((d) => { + foregroundNodes(d3.select(`#${d.source.uid}`)).classed(IS_HIGHLIGHTED, true); + foregroundNodes(d3.select(`#${d.target.uid}`)).classed(IS_HIGHLIGHTED, true); + }); +}; + +const restorePath = (parentLinks, parentNodes, baseOpacity) => { + parentLinks.forEach(({ uid }) => { + renewLinks(d3.select(`#${uid}`), baseOpacity).classed(IS_HIGHLIGHTED, false); + }); + + parentNodes.forEach((id) => { + d3.select(`#${id}`).classed(IS_HIGHLIGHTED, false); + }); + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(getOtherLinks(), baseOpacity); + renewNodes(getNodesNotLive()); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); + restoreNodes(); +}; + +export const restoreLinks = (baseOpacity) => { + /* + if there exist live links, reset to highlight out / pale + otherwise, reset to base + */ + + if (d3.selectAll(`.${IS_HIGHLIGHTED}`).empty()) { + renewLinks(d3.selectAll(`.${LINK_SELECTOR}`), baseOpacity); + renewNodes(d3.selectAll(`.${NODE_SELECTOR}`)); + return; + } + + backgroundLinks(getOtherLinks()); + backgroundNodes(getNodesNotLive()); +}; + +export const toggleLinkHighlight = (baseOpacity, d, idx, collection) => { + if (currentIsLive(idx, collection)) { + restorePath([d], [d.source.uid, d.target.uid], baseOpacity); + restoreNodes(); + return; + } + + highlightPath([d], [d.source.uid, d.target.uid]); +}; + +export const togglePathHighlights = (baseOpacity, d, idx, collection) => { + const parentLinks = getAllLinkAncestors(d); + const parentNodes = getAllNodeAncestors(d); + const currentNode = getCurrent(idx, collection); + + /* if this node is already live, make it unlive and reset its path */ + if (currentIsLive(idx, collection)) { + currentNode.classed(IS_HIGHLIGHTED, false); + restorePath(parentLinks, parentNodes, baseOpacity); + return; + } + + highlightPath(parentLinks, parentNodes); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/api_utils.js b/app/assets/javascripts/ci/pipeline_details/graph/api_utils.js new file mode 100644 index 00000000000..f9f47d1ea15 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/api_utils.js @@ -0,0 +1,13 @@ +import axios from '~/lib/utils/axios_utils'; +import { reportToSentry } from '~/ci/utils'; + +export const reportPerformance = (path, stats) => { + // FIXME: https://gitlab.com/gitlab-org/gitlab/-/issues/330245 + if (!path) { + return; + } + + axios.post(path, stats).catch((err) => { + reportToSentry('links_inner_perf', `error: ${err}`); + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue new file mode 100644 index 00000000000..f098d790736 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue @@ -0,0 +1,261 @@ +<script> +import { reportToSentry } from '~/ci/utils'; +import { + generateColumnsFromLayersListMemoized, + keepLatestDownstreamPipelines, +} from '~/ci/pipeline_details/utils/parsing_utils'; +import LinksLayer from '../../../common/private/job_links_layer.vue'; +import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from '../constants'; +import { validateConfigPaths } from '../utils'; +import LinkedGraphWrapper from './linked_graph_wrapper.vue'; +import LinkedPipelinesColumn from './linked_pipelines_column.vue'; +import StageColumnComponent from './stage_column_component.vue'; + +export default { + name: 'PipelineGraph', + components: { + LinksLayer, + LinkedGraphWrapper, + LinkedPipelinesColumn, + StageColumnComponent, + }, + props: { + configPaths: { + type: Object, + required: true, + validator: validateConfigPaths, + }, + pipeline: { + type: Object, + required: true, + }, + showLinks: { + type: Boolean, + required: true, + }, + viewType: { + type: String, + required: true, + }, + isLinkedPipeline: { + type: Boolean, + required: false, + default: false, + }, + computedPipelineInfo: { + type: Object, + required: false, + default: () => ({}), + }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, + type: { + type: String, + required: false, + default: MAIN, + }, + }, + pipelineTypeConstants: { + DOWNSTREAM, + UPSTREAM, + }, + CONTAINER_REF: 'PIPELINE_LINKS_CONTAINER_REF', + BASE_CONTAINER_ID: 'pipeline-links-container', + data() { + return { + hoveredJobName: '', + hoveredSourceJobName: '', + highlightedJobs: [], + measurements: { + width: 0, + height: 0, + }, + pipelineExpanded: { + jobName: '', + expanded: false, + }, + }; + }, + computed: { + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.pipeline.id}`; + }, + downstreamPipelines() { + return this.hasDownstreamPipelines + ? keepLatestDownstreamPipelines(this.pipeline.downstream) + : []; + }, + layout() { + return this.isStageView + ? this.pipeline.stages + : generateColumnsFromLayersListMemoized( + this.pipeline, + this.computedPipelineInfo.pipelineLayers, + ); + }, + hasDownstreamPipelines() { + return Boolean(this.pipeline?.downstream?.length > 0); + }, + hasUpstreamPipelines() { + return Boolean(this.pipeline?.upstream?.length > 0); + }, + isStageView() { + return this.viewType === STAGE_VIEW; + }, + linksData() { + return this.computedPipelineInfo?.linksData ?? null; + }, + metricsConfig() { + return { + path: this.configPaths.metricsPath, + collectMetrics: true, + }; + }, + showJobLinks() { + return !this.isStageView && this.showLinks; + }, + // The show downstream check prevents showing redundant linked columns + showDownstreamPipelines() { + return ( + this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM + ); + }, + // The show upstream check prevents showing redundant linked columns + showUpstreamPipelines() { + return ( + this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM + ); + }, + upstreamPipelines() { + return this.hasUpstreamPipelines ? this.pipeline.upstream : []; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, + mounted() { + this.getMeasurements(); + }, + methods: { + getMeasurements() { + this.measurements = { + width: this.$refs[this.containerId].scrollWidth, + height: this.$refs[this.containerId].scrollHeight, + }; + }, + onError(payload) { + this.$emit('error', payload); + }, + setJob(jobName) { + this.hoveredJobName = jobName; + }, + setSourceJob(jobName) { + this.hoveredSourceJobName = jobName; + }, + slidePipelineContainer() { + this.$refs.mainPipelineContainer.scrollBy({ + left: ONE_COL_WIDTH, + top: 0, + behavior: 'smooth', + }); + }, + togglePipelineExpanded(jobName, expanded) { + this.pipelineExpanded = { + expanded, + jobName: expanded ? jobName : '', + }; + }, + updateHighlightedJobs(jobs) { + this.highlightedJobs = jobs; + }, + }, +}; +</script> +<template> + <div class="js-pipeline-graph"> + <div + ref="mainPipelineContainer" + class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + :class="{ + 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline, + }" + > + <linked-graph-wrapper> + <template #upstream> + <linked-pipelines-column + v-if="showUpstreamPipelines" + :config-paths="configPaths" + :linked-pipelines="upstreamPipelines" + :column-title="__('Upstream')" + :show-links="showJobLinks" + :skip-retry-modal="skipRetryModal" + :type="$options.pipelineTypeConstants.UPSTREAM" + :view-type="viewType" + @error="onError" + @setSkipRetryModal="$emit('setSkipRetryModal')" + /> + </template> + <template #main> + <div :id="containerId" :ref="containerId"> + <links-layer + :pipeline-data="layout" + :pipeline-id="pipeline.id" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="hoveredJobName" + :links-data="linksData" + :metrics-config="metricsConfig" + :show-links="showJobLinks" + :view-type="viewType" + @error="onError" + @highlightedJobsChange="updateHighlightedJobs" + > + <stage-column-component + v-for="column in layout" + :key="column.id || column.name" + :name="column.name" + :groups="column.groups" + :action="column.status.action" + :highlighted-jobs="highlightedJobs" + :is-stage-view="isStageView" + :job-hovered="hoveredJobName" + :skip-retry-modal="skipRetryModal" + :source-job-hovered="hoveredSourceJobName" + :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipeline.id" + :user-permissions="pipeline.userPermissions" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" + @jobHover="setJob" + @updateMeasurements="getMeasurements" + /> + </links-layer> + </div> + </template> + <template #downstream> + <linked-pipelines-column + v-if="showDownstreamPipelines" + class="gl-mr-6" + :config-paths="configPaths" + :linked-pipelines="downstreamPipelines" + :column-title="__('Downstream')" + :skip-retry-modal="skipRetryModal" + :show-links="showJobLinks" + :type="$options.pipelineTypeConstants.DOWNSTREAM" + :view-type="viewType" + data-testid="downstream-pipelines" + @downstreamHovered="setSourceJob" + @pipelineExpandToggle="togglePipelineExpanded" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" + @scrollContainer="slidePipelineContainer" + @error="onError" + /> + </template> + </linked-graph-wrapper> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue new file mode 100644 index 00000000000..fb7dcb300f1 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue @@ -0,0 +1,176 @@ +<script> +import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import { STAGE_VIEW, LAYER_VIEW } from '../constants'; + +export default { + name: 'GraphViewSelector', + + components: { + GlAlert, + GlButton, + GlButtonGroup, + GlLoadingIcon, + GlToggle, + }, + + props: { + showLinks: { + type: Boolean, + required: true, + }, + tipPreviouslyDismissed: { + type: Boolean, + required: true, + }, + type: { + type: String, + required: true, + }, + }, + data() { + return { + hoverTipDismissed: false, + isToggleLoading: false, + isSwitcherLoading: false, + segmentSelectedType: this.type, + showLinksActive: false, + }; + }, + i18n: { + hoverTipText: __('Tip: Hover over a job to see the jobs it depends on to run.'), + linksLabelText: s__('GraphViewType|Show dependencies'), + viewLabelText: __('Group jobs by'), + }, + views: { + [STAGE_VIEW]: { + type: STAGE_VIEW, + text: { + primary: s__('GraphViewType|Stage'), + }, + }, + [LAYER_VIEW]: { + type: LAYER_VIEW, + text: { + primary: s__('GraphViewType|Job dependencies'), + }, + }, + }, + computed: { + showLinksToggle() { + return this.segmentSelectedType === LAYER_VIEW; + }, + showTip() { + return ( + this.showLinksToggle && + this.showLinks && + this.showLinksActive && + !this.tipPreviouslyDismissed && + !this.hoverTipDismissed + ); + }, + viewTypesList() { + return Object.keys(this.$options.views).map((key) => { + return { + value: key, + text: this.$options.views[key].text.primary, + }; + }); + }, + }, + watch: { + /* + How does this reset the loading? As we note in the methods comment below, + the loader is set to on before the update work is undertaken (in the parent). + Once the work is complete, one of these values will change, since that's the + point of the work. When that happens, the related value will update and we are done. + + The bonus for this approach is that it works the same whichever "direction" + the work goes in. + */ + showLinks() { + this.isToggleLoading = false; + }, + type() { + this.isSwitcherLoading = false; + }, + }, + methods: { + dismissTip() { + this.hoverTipDismissed = true; + this.$emit('dismissHoverTip'); + }, + isCurrentType(type) { + return this.segmentSelectedType === type; + }, + /* + In both toggle methods, we use setTimeout so that the loading indicator displays, + then the work is done to update the DOM. The process is: + → user clicks + → call stack: set loading to true + → render: the loading icon appears on the screen + → callback queue: now do the work to calculate the new view / links + (note: this work is done in the parent after the event is emitted) + + setTimeout is how we move the work to the callback queue. + We can't use nextTick because that is called before the render loop. + + See https://www.hesselinkwebdesign.nl/2019/nexttick-vs-settimeout-in-vue/ for more details. + */ + setViewType(type) { + if (!this.isCurrentType(type)) { + this.isSwitcherLoading = true; + this.segmentSelectedType = type; + setTimeout(() => { + this.$emit('updateViewType', type); + }); + } + }, + toggleShowLinksActive(val) { + this.isToggleLoading = true; + setTimeout(() => { + this.$emit('updateShowLinksState', val); + }); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4"> + <gl-loading-icon + v-if="isSwitcherLoading" + data-testid="switcher-loading-state" + class="gl-absolute gl-w-full gl-bg-white gl-opacity-5 gl-z-index-2" + size="lg" + /> + <span class="gl-font-weight-bold">{{ $options.i18n.viewLabelText }}</span> + <gl-button-group class="gl-mx-4"> + <gl-button + v-for="viewType in viewTypesList" + :key="viewType.value" + :selected="isCurrentType(viewType.value)" + @click="setViewType(viewType.value)" + > + {{ viewType.text }} + </gl-button> + </gl-button-group> + + <div v-if="showLinksToggle" class="gl-display-flex gl-align-items-center"> + <gl-toggle + v-model="showLinksActive" + data-testid="show-links-toggle" + class="gl-mx-4" + :label="$options.i18n.linksLabelText" + :is-loading="isToggleLoading" + label-position="left" + @change="toggleShowLinksActive" + /> + </div> + </div> + <gl-alert v-if="showTip" class="gl-my-5" variant="tip" @dismiss="dismissTip"> + {{ $options.i18n.hoverTipText }} + </gl-alert> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue new file mode 100644 index 00000000000..7538ad87af8 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_group_dropdown.vue @@ -0,0 +1,110 @@ +<script> +import { reportToSentry } from '~/ci/utils'; +import { JOB_DROPDOWN, SINGLE_JOB } from '../constants'; +import JobItem from './job_item.vue'; + +/** + * Renders the dropdown for the pipeline graph. + * + * The object provided as `group` corresponds to app/serializers/job_group_entity.rb. + * + */ +export default { + components: { + JobItem, + }, + props: { + group: { + type: Object, + required: true, + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + cssClassJobName: { + type: [String, Array], + required: false, + default: '', + }, + stageName: { + type: String, + required: false, + default: '', + }, + }, + jobItemTypes: { + jobDropdown: JOB_DROPDOWN, + singleJob: SINGLE_JOB, + }, + computed: { + computedJobId() { + return this.pipelineId > -1 ? `${this.group.name}-${this.pipelineId}` : ''; + }, + tooltipText() { + const { name, status } = this.group; + return `${name} - ${status.label}`; + }, + jobGroupClasses() { + return [this.cssClassJobName, `job-${this.group.status.group}`]; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('job_group_dropdown', `error: ${err}, info: ${info}`); + }, + methods: { + pipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + }, + }, +}; +</script> +<template> + <!-- eslint-disable @gitlab/vue-no-data-toggle --> + <div + :id="computedJobId" + class="ci-job-dropdown-container dropdown dropright" + data-qa-selector="job_dropdown_container" + > + <button + type="button" + data-toggle="dropdown" + data-display="static" + :class="jobGroupClasses" + class="dropdown-menu-toggle gl-pipeline-job-width! gl-pr-4!" + > + <div class="gl-display-flex gl-align-items-stretch gl-justify-content-space-between"> + <job-item + :type="$options.jobItemTypes.jobDropdown" + :group-tooltip="tooltipText" + :job="group" + :stage-name="stageName" + /> + + <div class="gl-font-weight-100 gl-font-size-lg gl-ml-n4 gl-align-self-center"> + {{ group.size }} + </div> + </div> + </button> + + <ul + class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown" + data-qa-selector="jobs_dropdown_menu" + > + <li class="scrollable-menu"> + <ul> + <li v-for="job in group.jobs" :key="job.id"> + <job-item + :dropdown-length="group.size" + :job="job" + :type="$options.jobItemTypes.singleJob" + css-class-job-name="pipeline-job-item" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + /> + </li> + </ul> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue new file mode 100644 index 00000000000..4298052d1c0 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -0,0 +1,396 @@ +<script> +import { GlBadge, GlForm, GlFormCheckbox, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; +import { reportToSentry } from '~/ci/utils'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { __, s__, sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import ActionComponent from '../../../common/private/job_action_component.vue'; +import JobNameComponent from '../../../common/private/job_name_component.vue'; +import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from '../constants'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + confirmationModalDocLink: helpPagePath('/ci/pipelines/downstream_pipelines'), + i18n: { + bridgeBadgeText: __('Trigger job'), + bridgeRetryText: s__( + 'PipelineGraph|Downstream pipeline might not display in the graph while the new downstream pipeline is being created.', + ), + unauthorizedTooltip: __('You are not authorized to run this manual job'), + confirmationModal: { + title: s__('PipelineGraph|Are you sure you want to retry %{jobName}?'), + description: s__( + 'PipelineGraph|Retrying a trigger job will create a new downstream pipeline.', + ), + linkText: s__('PipelineGraph|What is a downstream pipeline?'), + footer: __("Don't show this again"), + actionPrimary: { text: __('Retry') }, + actionCancel: { text: __('Cancel') }, + }, + runAgainTooltipText: __('Run again'), + }, + hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', + components: { + ActionComponent, + CiIcon, + GlBadge, + GlForm, + GlFormCheckbox, + GlLink, + GlModal, + JobNameComponent, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + cssClassJobName: { + type: [String, Array, Object], + required: false, + default: '', + }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, + groupTooltip: { + type: String, + required: false, + default: '', + }, + jobHovered: { + type: String, + required: false, + default: '', + }, + pipelineExpanded: { + type: Object, + required: false, + default: () => ({}), + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, + stageName: { + type: String, + required: false, + default: '', + }, + type: { + type: String, + required: false, + default: SINGLE_JOB, + }, + }, + data() { + return { + currentSkipModalValue: this.skipRetryModal, + showConfirmationModal: false, + shouldTriggerActionClick: false, + }; + }, + computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, + computedJobId() { + return this.pipelineId > -1 ? `${this.job.name}-${this.pipelineId}` : ''; + }, + detailsPath() { + return this.status.detailsPath; + }, + hasDetails() { + return this.status.hasDetails; + }, + hasRetryAction() { + return Boolean(this.job?.status?.action?.title === RETRY_ACTION_TITLE); + }, + isRetryableBridge() { + return this.isBridge && this.hasRetryAction; + }, + isSingleItem() { + return this.type === SINGLE_JOB; + }, + isBridge() { + return this.kind === BRIDGE_KIND; + }, + kind() { + return this.job?.kind || ''; + }, + nameComponent() { + return this.hasDetails ? 'gl-link' : 'div'; + }, + retryTriggerJobWarningText() { + return sprintf(this.$options.i18n.confirmationModal.title, { + jobName: this.job.name, + }); + }, + showStageName() { + return Boolean(this.stageName); + }, + status() { + return this.job && this.job.status ? this.job.status : {}; + }, + testId() { + return this.hasDetails ? 'job-with-link' : 'job-without-link'; + }, + tooltipText() { + if (this.groupTooltip) { + return this.groupTooltip; + } + + const textBuilder = []; + const { name: jobName } = this.job; + + if (jobName) { + textBuilder.push(jobName); + } + + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { + textBuilder.push('-'); + } + + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } + } + + return textBuilder.join(' '); + }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasAction() { + return this.job.status && this.job.status.action && this.job.status.action.path; + }, + hasUnauthorizedManualAction() { + return ( + !this.hasAction && + this.job.status?.group === 'manual' && + this.job.status?.label?.includes('(not allowed)') + ); + }, + unauthorizedManualActionIcon() { + /* + The action object is not available when the user cannot run the action. + So we can show the correct icon, extract the action name from the label instead: + "manual play action (not allowed)" or "manual stop action (not allowed)" + */ + return this.job.status?.label?.split(' ')[1]; + }, + relatedDownstreamHovered() { + return this.job.name === this.sourceJobHovered; + }, + relatedDownstreamExpanded() { + return this.job.name === this.pipelineExpanded.jobName && this.pipelineExpanded.expanded; + }, + jobClasses() { + return [ + { + [this.$options.hoverClass]: + this.relatedDownstreamHovered || this.relatedDownstreamExpanded, + }, + { 'gl-rounded-lg': this.isBridge }, + this.cssClassJobName, + { + [`job-${this.status.group}`]: this.isSingleItem, + }, + ]; + }, + withConfirmationModal() { + return this.isRetryableBridge && !this.skipRetryModal; + }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === 'retry' && group === 'success' + ? this.$options.i18n.runAgainTooltipText + : title; + }, + }, + watch: { + skipRetryModal(val) { + this.currentSkipModalValue = val; + this.shouldTriggerActionClick = false; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('job_item', `error: ${err}, info: ${info}`); + }, + methods: { + handleConfirmationModalPreferences() { + if (this.currentSkipModalValue) { + this.$emit('setSkipRetryModal'); + localStorage.setItem(SKIP_RETRY_MODAL_KEY, String(this.currentSkipModalValue)); + } + }, + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, + jobItemClick(evt) { + if (this.isSingleItem) { + /* + This is so the jobDropdown still toggles. Issue to refactor: + https://gitlab.com/gitlab-org/gitlab/-/issues/267117 + */ + evt.stopPropagation(); + } + + this.hideTooltips(); + }, + pipelineActionRequestComplete() { + this.$emit('pipelineActionRequestComplete'); + + if (this.isBridge) { + this.$toast.show(this.$options.i18n.bridgeRetryText); + } + }, + executePendingAction() { + this.shouldTriggerActionClick = true; + }, + showActionConfirmationModal() { + this.showConfirmationModal = true; + }, + toggleSkipRetryModalCheckbox() { + this.currentSkipModalValue = !this.currentSkipModalValue; + }, + }, +}; +</script> +<template> + <div + :id="computedJobId" + class="ci-job-component gl-display-flex gl-justify-content-space-between gl-pipeline-job-width" + data-qa-selector="job_item_container" + > + <component + :is="nameComponent" + v-gl-tooltip="{ + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }" + :title="tooltipText" + :class="jobClasses" + :href="detailsPath" + class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none gl-w-full" + :data-testid="testId" + data-qa-selector="job_link" + @click="jobItemClick" + @mouseout="hideTooltips" + > + <div class="gl-display-flex gl-align-items-center gl-flex-grow-1"> + <ci-icon :size="24" :status="job.status" class="gl-line-height-0" /> + <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width"> + <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div> + <div + v-if="showStageName" + data-testid="stage-name-in-job" + class="gl-text-truncate gl-pr-9 gl-font-sm gl-text-gray-500 gl-line-height-normal" + > + {{ stageName }} + </div> + </div> + </div> + <gl-badge v-if="isBridge" class="gl-mt-3" variant="info" size="sm"> + {{ $options.i18n.bridgeBadgeText }} + </gl-badge> + </component> + + <action-component + v-if="hasAction" + :tooltip-text="jobActionTooltipText" + :link="status.action.path" + :action-icon="status.action.icon" + class="gl-mr-1" + :should-trigger-click="shouldTriggerActionClick" + :with-confirmation-modal="withConfirmationModal" + data-qa-selector="job_action_button" + @actionButtonClicked="handleConfirmationModalPreferences" + @pipelineActionRequestComplete="pipelineActionRequestComplete" + @showActionConfirmationModal="showActionConfirmationModal" + /> + <action-component + v-if="hasUnauthorizedManualAction" + disabled + :tooltip-text="$options.i18n.unauthorizedTooltip" + :action-icon="unauthorizedManualActionIcon" + :link="`unauthorized-${computedJobId}`" + class="gl-mr-1" + /> + <gl-modal + v-if="showConfirmationModal" + ref="modal" + v-model="showConfirmationModal" + modal-id="action-confirmation-modal" + :title="retryTriggerJobWarningText" + :action-cancel="$options.i18n.confirmationModal.actionCancel" + :action-primary="$options.i18n.confirmationModal.actionPrimary" + @primary="executePendingAction" + @close="handleConfirmationModalPreferences" + @hide="handleConfirmationModalPreferences" + > + <p class="gl-mb-1">{{ $options.i18n.confirmationModal.description }}</p> + <gl-link :href="$options.confirmationModalDocLink" target="_blank">{{ + $options.i18n.confirmationModal.linkText + }}</gl-link> + <div class="gl-mt-4 gl-display-flex"> + <gl-form> + <gl-form-checkbox class="gl-min-h-0" @input="toggleSkipRetryModalCheckbox" /> + </gl-form> + <p class="gl-m-0">{{ $options.i18n.confirmationModal.footer }}</p> + </div> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue new file mode 100644 index 00000000000..fb2280d971a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue @@ -0,0 +1,7 @@ +<template> + <div class="gl-display-flex"> + <slot name="upstream"></slot> + <slot name="main"></slot> + <slot name="downstream"></slot> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue new file mode 100644 index 00000000000..d6adaf78da4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -0,0 +1,306 @@ +<script> +import { + GlBadge, + GlButton, + GlLink, + GlLoadingIcon, + GlTooltip, + GlTooltipDirective, +} from '@gitlab/ui'; +import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; +import { __, sprintf } from '~/locale'; +import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { reportToSentry } from '~/ci/utils'; +import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from '../constants'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CiIcon, + GlBadge, + GlButton, + GlLink, + GlLoadingIcon, + GlTooltip, + }, + styles: { + actionSizeClasses: ['gl-h-7 gl-w-7'], + flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'], + flatRightBorder: ['gl-rounded-bottom-right-none!', 'gl-rounded-top-right-none!'], + }, + props: { + columnTitle: { + type: String, + required: true, + }, + expanded: { + type: Boolean, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + pipeline: { + type: Object, + required: true, + }, + type: { + type: String, + required: true, + }, + }, + data() { + return { + hasActionTooltip: false, + isActionLoading: false, + isExpandBtnFocus: false, + }; + }, + computed: { + action() { + if (this.isDownstream) { + if (this.isCancelable) { + return { + icon: 'cancel', + method: this.cancelPipeline, + ariaLabel: __('Cancel downstream pipeline'), + }; + } + if (this.isRetryable) { + return { + icon: 'retry', + method: this.retryPipeline, + ariaLabel: __('Retry downstream pipeline'), + }; + } + } + + return {}; + }, + buttonBorderClasses() { + return this.isUpstream + ? ['gl-border-r-0!', ...this.$options.styles.flatRightBorder] + : ['gl-border-l-0!', ...this.$options.styles.flatLeftBorder]; + }, + buttonShadowClass() { + return this.isExpandBtnFocus ? '' : 'gl-shadow-none!'; + }, + buttonId() { + return `js-linked-pipeline-${this.pipeline.id}`; + }, + cardClasses() { + return this.isDownstream + ? this.$options.styles.flatRightBorder + : this.$options.styles.flatLeftBorder; + }, + expandedIcon() { + if (this.isUpstream) { + return this.expanded ? 'chevron-lg-right' : 'chevron-lg-left'; + } + return this.expanded ? 'chevron-lg-left' : 'chevron-lg-right'; + }, + expandBtnText() { + return this.expanded ? __('Collapse jobs') : __('Expand jobs'); + }, + childPipeline() { + return this.isDownstream && this.isSameProject; + }, + downstreamTitle() { + return this.childPipeline ? this.sourceJobName : this.pipeline.project.name; + }, + flexDirection() { + return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; + }, + graphqlPipelineId() { + return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id); + }, + hasUpdatePipelinePermissions() { + return Boolean(this.pipeline?.userPermissions?.updatePipeline); + }, + isCancelable() { + return Boolean(this.pipeline?.cancelable && this.hasUpdatePipelinePermissions); + }, + isDownstream() { + return this.type === DOWNSTREAM; + }, + isRetryable() { + return Boolean(this.pipeline?.retryable && this.hasUpdatePipelinePermissions); + }, + isSameProject() { + return !this.pipeline.multiproject; + }, + isUpstream() { + return this.type === UPSTREAM; + }, + label() { + if (this.parentPipeline) { + return __('Parent'); + } + if (this.childPipeline) { + return __('Child'); + } + return __('Multi-project'); + }, + parentPipeline() { + return this.isUpstream && this.isSameProject; + }, + pipelineIsLoading() { + return Boolean(this.isLoading || this.pipeline.isLoading); + }, + pipelineStatus() { + return this.pipeline.status; + }, + projectName() { + return this.pipeline.project.name; + }, + showAction() { + return Boolean(this.action?.method && this.action?.icon && this.action?.ariaLabel); + }, + showCardTooltip() { + return !this.hasActionTooltip && !this.isExpandBtnFocus; + }, + sourceJobName() { + return this.pipeline.sourceJob?.name ?? ''; + }, + sourceJobInfo() { + return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : ''; + }, + cardTooltipText() { + return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - + ${this.sourceJobInfo}`; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`); + }, + methods: { + cancelPipeline() { + this.executePipelineAction(CancelPipelineMutation); + }, + async executePipelineAction(mutation) { + try { + this.isActionLoading = true; + + await this.$apollo.mutate({ + mutation, + variables: { + id: this.graphqlPipelineId, + }, + }); + this.$emit('refreshPipelineGraph'); + } catch { + this.$emit('error', { type: ACTION_FAILURE }); + } finally { + this.isActionLoading = false; + } + }, + hideTooltips() { + this.$root.$emit(BV_HIDE_TOOLTIP); + }, + onClickLinkedPipeline() { + this.hideTooltips(); + this.$emit('pipelineClicked', this.$refs.linkedPipeline); + this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded); + }, + onDownstreamHovered() { + this.$emit('downstreamHovered', this.sourceJobName); + }, + onDownstreamHoverLeave() { + this.$emit('downstreamHovered', ''); + }, + retryPipeline() { + this.executePipelineAction(RetryPipelineMutation); + }, + setActionTooltip(flag) { + this.hasActionTooltip = flag; + }, + setExpandBtnActiveState(flag) { + this.isExpandBtnFocus = flag; + }, + }, +}; +</script> + +<template> + <div + ref="linkedPipeline" + class="gl-h-full gl-display-flex! gl-px-2" + :class="flexDirection" + data-qa-selector="linked_pipeline_container" + @mouseover="onDownstreamHovered" + @mouseleave="onDownstreamHoverLeave" + > + <gl-tooltip v-if="showCardTooltip" :target="() => $refs.linkedPipeline"> + {{ cardTooltipText }} + </gl-tooltip> + <div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses"> + <div class="gl-display-flex gl-gap-x-3"> + <ci-icon v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" /> + <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> + <div + class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" + > + <span + class="gl-text-truncate" + data-testid="downstream-title" + data-qa-selector="downstream_title_content" + > + {{ downstreamTitle }} + </span> + <div class="gl-text-truncate"> + <gl-link + class="gl-text-blue-500! gl-font-sm" + :href="pipeline.path" + data-testid="pipelineLink" + >#{{ pipeline.id }}</gl-link + > + </div> + </div> + <gl-button + v-if="showAction" + v-gl-tooltip + :title="action.ariaLabel" + :loading="isActionLoading" + :icon="action.icon" + class="gl-rounded-full!" + :class="$options.styles.actionSizeClasses" + :aria-label="action.ariaLabel" + @click="action.method" + @mouseover="setActionTooltip(true)" + @mouseout="setActionTooltip(false)" + /> + <div v-else :class="$options.styles.actionSizeClasses"></div> + </div> + <div class="gl-pt-2"> + <gl-badge size="sm" variant="info" data-testid="downstream-pipeline-label"> + {{ label }} + </gl-badge> + </div> + </div> + <div class="gl-display-flex"> + <gl-button + :id="buttonId" + v-gl-tooltip + :title="expandBtnText" + class="gl-border! gl-rounded-lg!" + :class="[`js-pipeline-expand-${pipeline.id}`, buttonBorderClasses, buttonShadowClass]" + :icon="expandedIcon" + :aria-label="expandBtnText" + data-testid="expand-pipeline-button" + data-qa-selector="expand_linked_pipeline_button" + @mouseover="setExpandBtnActiveState(true)" + @mouseout="setExpandBtnActiveState(false)" + @focus="setExpandBtnActiveState(true)" + @blur="setExpandBtnActiveState(false)" + @click="onClickLinkedPipeline" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue new file mode 100644 index 00000000000..2de7e43c9b1 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue @@ -0,0 +1,247 @@ +<script> +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import { reportToSentry } from '~/ci/utils'; +import { LOAD_FAILURE } from '../../constants'; +import { ONE_COL_WIDTH, UPSTREAM, LAYER_VIEW, STAGE_VIEW } from '../constants'; +import { + calculatePipelineLayersInfo, + getQueryHeaders, + serializeLoadErrors, + toggleQueryPollingByVisibility, + unwrapPipelineData, + validateConfigPaths, +} from '../utils'; +import LinkedPipeline from './linked_pipeline.vue'; + +export default { + components: { + LinkedPipeline, + PipelineGraph: () => import('./graph_component.vue'), + }, + props: { + columnTitle: { + type: String, + required: true, + }, + configPaths: { + type: Object, + required: true, + validator: validateConfigPaths, + }, + linkedPipelines: { + type: Array, + required: true, + }, + showLinks: { + type: Boolean, + required: true, + }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, + type: { + type: String, + required: true, + }, + viewType: { + type: String, + required: true, + }, + }, + data() { + return { + currentPipeline: null, + loadingPipelineId: null, + pipelineLayers: {}, + pipelineExpanded: false, + }; + }, + titleClasses: [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-3', + 'gl-mb-5', + ], + minWidth: `${ONE_COL_WIDTH}px`, + computed: { + columnClass() { + const positionValues = { + right: 'gl-ml-6', + left: 'gl-mx-6', + }; + + return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; + }, + computedTitleClasses() { + const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-linked-pipeline-padding'] : []; + + return [...this.$options.titleClasses, ...positionalClasses]; + }, + graphPosition() { + return this.isUpstream ? 'left' : 'right'; + }, + graphViewType() { + return this.currentPipeline?.usesNeeds ? this.viewType : STAGE_VIEW; + }, + isUpstream() { + return this.type === UPSTREAM; + }, + minWidth() { + return this.isUpstream ? 0 : this.$options.minWidth; + }, + }, + methods: { + getPipelineData(pipeline) { + const projectPath = pipeline.project.fullPath; + + this.$apollo.addSmartQuery('currentPipeline', { + query: getPipelineDetails, + pollInterval: 10000, + context() { + return getQueryHeaders(this.configPaths.graphqlResourceEtag); + }, + variables() { + return { + projectPath, + iid: pipeline.iid, + }; + }, + update(data) { + /* + This check prevents the pipeline from being overwritten + when a poll times out and the data returned is empty. + This can be removed once the timeout behavior is updated. + See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213. + */ + + if (!data?.project?.pipeline) { + return this.currentPipeline; + } + + return unwrapPipelineData(projectPath, JSON.parse(JSON.stringify(data))); + }, + result() { + this.loadingPipelineId = null; + this.$emit('scrollContainer'); + }, + error(err) { + this.$emit('error', { type: LOAD_FAILURE, skipSentry: true }); + + reportToSentry( + 'linked_pipelines_column', + `error type: ${LOAD_FAILURE}, error: ${serializeLoadErrors(err)}`, + ); + }, + }); + + toggleQueryPollingByVisibility(this.$apollo.queries.currentPipeline); + }, + getPipelineLayers(id) { + if (this.viewType === LAYER_VIEW && !this.pipelineLayers[id]) { + this.pipelineLayers[id] = calculatePipelineLayersInfo( + this.currentPipeline, + this.$options.name, + this.configPaths.metricsPath, + ); + } + + return this.pipelineLayers[id]; + }, + isExpanded(id) { + return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id); + }, + isLoadingPipeline(id) { + return this.loadingPipelineId === id; + }, + onPipelineClick(pipeline) { + /* If the clicked pipeline has been expanded already, close it, clear, exit */ + if (this.currentPipeline?.id === pipeline.id) { + this.pipelineExpanded = false; + this.currentPipeline = null; + return; + } + + /* Set the loading id */ + this.loadingPipelineId = pipeline.id; + + /* + Expand the pipeline. + If this was not a toggle close action, and + it was already showing a different pipeline, then + this will be a no-op, but that doesn't matter. + */ + this.pipelineExpanded = true; + + this.getPipelineData(pipeline); + }, + onDownstreamHovered(jobName) { + this.$emit('downstreamHovered', jobName); + }, + onPipelineExpandToggle(jobName, expanded) { + // Highlighting only applies to downstream pipelines + if (this.isUpstream) { + return; + } + + this.$emit('pipelineExpandToggle', jobName, expanded); + }, + showContainer(id) { + return this.isExpanded(id) || this.isLoadingPipeline(id); + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex"> + <div :class="columnClass" class="linked-pipelines-column"> + <div data-testid="linked-column-title" :class="computedTitleClasses"> + {{ columnTitle }} + </div> + <ul class="gl-pl-0"> + <li + v-for="pipeline in linkedPipelines" + :key="pipeline.id" + class="gl-display-flex gl-mb-3" + :class="{ 'gl-flex-direction-row-reverse': isUpstream }" + > + <linked-pipeline + class="gl-display-inline-block" + :is-loading="isLoadingPipeline(pipeline.id)" + :pipeline="pipeline" + :column-title="columnTitle" + :type="type" + :expanded="isExpanded(pipeline.id)" + @downstreamHovered="onDownstreamHovered" + @pipelineClicked="onPipelineClick(pipeline)" + @pipelineExpandToggle="onPipelineExpandToggle" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" + /> + <div + v-if="showContainer(pipeline.id)" + :style="{ minWidth }" + class="gl-display-inline-block" + > + <pipeline-graph + v-if="isExpanded(pipeline.id)" + :type="type" + class="gl-inline-block gl-mt-n2" + :config-paths="configPaths" + :pipeline="currentPipeline" + :computed-pipeline-info="getPipelineLayers(pipeline.id)" + :show-links="showLinks" + :skip-retry-modal="skipRetryModal" + :is-linked-pipeline="true" + :view-type="graphViewType" + @setSkipRetryModal="$emit('setSkipRetryModal')" + /> + </div> + </li> + </ul> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/links_inner.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/links_inner.vue new file mode 100644 index 00000000000..09285525c38 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/links_inner.vue @@ -0,0 +1,162 @@ +<script> +import { isEmpty } from 'lodash'; +import { STAGE_VIEW } from '~/ci/pipeline_details/graph/constants'; +import { createJobsHash, generateJobNeedsDict } from '~/ci/pipeline_details/utils'; +import { reportToSentry } from '~/ci/utils'; +import { DRAW_FAILURE } from '../../constants'; +import { generateLinksData } from '../../utils/drawing_utils'; + +export default { + name: 'LinksInner', + STROKE_WIDTH: 2, + props: { + containerId: { + type: String, + required: true, + }, + containerMeasurements: { + type: Object, + required: true, + }, + linksData: { + type: Array, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + pipelineData: { + type: Array, + required: true, + }, + defaultLinkColor: { + type: String, + required: false, + default: 'gl-stroke-gray-200', + }, + highlightedJob: { + type: String, + required: false, + default: '', + }, + viewType: { + type: String, + required: false, + default: STAGE_VIEW, + }, + }, + data() { + return { + links: [], + needsObject: null, + }; + }, + computed: { + hasHighlightedJob() { + return Boolean(this.highlightedJob); + }, + isPipelineDataEmpty() { + return isEmpty(this.pipelineData); + }, + highlightedJobs() { + // If you are hovering on a job, then the jobs we want to highlight are: + // The job you are currently hovering + all of its needs. + return this.hasHighlightedJob + ? [this.highlightedJob, ...this.needsObject[this.highlightedJob]] + : []; + }, + highlightedLinks() { + // If you are hovering on a job, then the links we want to highlight are: + // All the links whose `source` and `target` are highlighted jobs. + if (this.hasHighlightedJob) { + const filteredLinks = this.links.filter((link) => { + return ( + this.highlightedJobs.includes(link.source) && this.highlightedJobs.includes(link.target) + ); + }); + + return filteredLinks.map((link) => link.ref); + } + + return []; + }, + viewBox() { + return [0, 0, this.containerMeasurements.width, this.containerMeasurements.height]; + }, + }, + watch: { + highlightedJob() { + // On first hover, generate the needs reference + if (!this.needsObject) { + const jobs = createJobsHash(this.pipelineData); + this.needsObject = generateJobNeedsDict(jobs) ?? {}; + } + }, + highlightedJobs(jobs) { + this.$emit('highlightedJobsChange', jobs); + }, + linksData() { + this.calculateLinkData(); + }, + viewType() { + /* + We need to wait a tick so that the layout reflows + before the links refresh. + */ + this.$nextTick(() => { + this.calculateLinkData(); + }); + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, + mounted() { + if (!isEmpty(this.linksData)) { + this.calculateLinkData(); + } + }, + methods: { + isLinkHighlighted(linkRef) { + return this.highlightedLinks.includes(linkRef); + }, + calculateLinkData() { + try { + this.links = generateLinksData(this.linksData, this.containerId, `-${this.pipelineId}`); + } catch (err) { + this.$emit('error', { type: DRAW_FAILURE, reportToSentry: false }); + reportToSentry(this.$options.name, err); + } + }, + getLinkClasses(link) { + return [ + this.isLinkHighlighted(link.ref) ? 'gl-stroke-blue-400' : this.defaultLinkColor, + { 'gl-opacity-3': this.hasHighlightedJob && !this.isLinkHighlighted(link.ref) }, + ]; + }, + }, +}; +</script> +<template> + <div class="gl-display-flex gl-relative"> + <svg + id="link-svg" + class="gl-absolute gl-pointer-events-none" + :viewBox="viewBox" + :width="`${containerMeasurements.width}px`" + :height="`${containerMeasurements.height}px`" + > + <path + v-for="link in links" + :key="link.path" + :ref="link.ref" + :d="link.path" + class="gl-fill-transparent gl-transition-duration-slow gl-transition-timing-function-ease" + :class="getLinkClasses(link)" + :stroke-width="$options.STROKE_WIDTH" + /> + </svg> + <slot></slot> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue new file mode 100644 index 00000000000..bcd7705669e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue @@ -0,0 +1,29 @@ +<script> +export default { + props: { + stageClasses: { + type: String, + required: false, + default: '', + }, + jobClasses: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <div> + <div class="gl-display-flex gl-align-items-center gl-w-full gl-mb-5" :class="stageClasses"> + <slot name="stages"> </slot> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full" + :class="jobClasses" + > + <slot name="jobs"> </slot> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue new file mode 100644 index 00000000000..1401bdba5ca --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -0,0 +1,196 @@ +<script> +import { escape, isEmpty } from 'lodash'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { reportToSentry } from '~/ci/utils'; +import RootGraphLayout from './root_graph_layout.vue'; +import JobGroupDropdown from './job_group_dropdown.vue'; +import JobItem from './job_item.vue'; + +export default { + components: { + ActionComponent, + JobGroupDropdown, + JobItem, + RootGraphLayout, + }, + mixins: [glFeatureFlagMixin()], + props: { + groups: { + type: Array, + required: true, + }, + name: { + type: String, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + action: { + type: Object, + required: false, + default: () => ({}), + }, + highlightedJobs: { + type: Array, + required: false, + default: () => [], + }, + isStageView: { + type: Boolean, + required: false, + default: false, + }, + jobHovered: { + type: String, + required: false, + default: '', + }, + pipelineExpanded: { + type: Object, + required: false, + default: () => ({}), + }, + skipRetryModal: { + type: Boolean, + required: false, + default: false, + }, + sourceJobHovered: { + type: String, + required: false, + default: '', + }, + userPermissions: { + type: Object, + required: true, + }, + }, + jobClasses: [ + 'gl-p-3', + 'gl-border-gray-100', + 'gl-border-solid', + 'gl-border-1', + 'gl-bg-white', + 'gl-rounded-7', + 'gl-hover-bg-gray-50', + 'gl-focus-bg-gray-50', + 'gl-hover-text-gray-900', + 'gl-focus-text-gray-900', + 'gl-hover-border-gray-200', + 'gl-focus-border-gray-200', + ], + titleClasses: [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-3', + ], + computed: { + canUpdatePipeline() { + return this.userPermissions.updatePipeline; + }, + columnSpacingClass() { + return this.isStageView ? 'gl-px-6' : 'gl-px-9'; + }, + hasAction() { + return !isEmpty(this.action); + }, + showStageName() { + return !this.isStageView; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('stage_column_component', `error: ${err}, info: ${info}`); + }, + mounted() { + this.$emit('updateMeasurements'); + }, + methods: { + getGroupId(group) { + return group.name; + }, + groupId(group) { + return `ci-badge-${escape(group.name)}`; + }, + isFadedOut(jobName) { + return this.highlightedJobs.length > 1 && !this.highlightedJobs.includes(jobName); + }, + isParallel(group) { + return group.size > 1 && group.jobs.length > 1; + }, + singleJobExists(group) { + const firstJobDefined = Boolean(group.jobs?.[0]); + + if (!firstJobDefined) { + reportToSentry('stage_column_component', 'undefined_job_hunt'); + } + + return group.size === 1 && firstJobDefined; + }, + }, +}; +</script> +<template> + <root-graph-layout :class="columnSpacingClass" data-testid="stage-column"> + <template #stages> + <div + data-testid="stage-column-title" + class="gl-display-flex gl-justify-content-space-between gl-relative" + :class="$options.titleClasses" + > + <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p"> + {{ name }} + </span> + <action-component + v-if="hasAction && canUpdatePipeline" + :action-icon="action.icon" + :tooltip-text="action.title" + :link="action.path" + class="js-stage-action" + @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + /> + </div> + </template> + <template #jobs> + <div + v-for="group in groups" + :id="groupId(group)" + :key="getGroupId(group)" + data-testid="stage-column-group" + class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" + @mouseenter="$emit('jobHover', group.name)" + @mouseleave="$emit('jobHover', '')" + > + <job-item + v-if="singleJobExists(group)" + :job="group.jobs[0]" + :job-hovered="jobHovered" + :skip-retry-modal="skipRetryModal" + :source-job-hovered="sourceJobHovered" + :pipeline-expanded="pipelineExpanded" + :pipeline-id="pipelineId" + :stage-name="showStageName ? group.stageName : ''" + :css-class-job-name="$options.jobClasses" + :class="[ + { 'gl-opacity-3': isFadedOut(group.name) }, + 'gl-transition-duration-slow gl-transition-timing-function-ease', + ]" + @pipelineActionRequestComplete="$emit('refreshPipelineGraph')" + @setSkipRetryModal="$emit('setSkipRetryModal')" + /> + <div v-else-if="isParallel(group)" :class="{ 'gl-opacity-3': isFadedOut(group.name) }"> + <job-group-dropdown + :group="group" + :stage-name="showStageName ? group.stageName : ''" + :pipeline-id="pipelineId" + :css-class-job-name="$options.jobClasses" + /> + </div> + </div> + </template> + </root-graph-layout> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/constants.js b/app/assets/javascripts/ci/pipeline_details/graph/constants.js new file mode 100644 index 00000000000..e650a48bc2a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/constants.js @@ -0,0 +1,26 @@ +export const DOWNSTREAM = 'downstream'; +export const MAIN = 'main'; +export const UPSTREAM = 'upstream'; + +/* + this value is based on the gl-pipeline-job-width class + plus some extra for the margins +*/ +export const ONE_COL_WIDTH = 180; + +export const STAGE_VIEW = 'stage'; +export const LAYER_VIEW = 'layer'; + +export const SKIP_RETRY_MODAL_KEY = 'skip_retry_modal'; +export const VIEW_TYPE_KEY = 'pipeline_graph_view_type'; + +export const SINGLE_JOB = 'single_job'; +export const JOB_DROPDOWN = 'job_dropdown'; + +export const BUILD_KIND = 'BUILD'; +export const BRIDGE_KIND = 'BRIDGE'; + +export const ACTION_FAILURE = 'action_failure'; +export const IID_FAILURE = 'missing_iid'; + +export const RETRY_ACTION_TITLE = 'Retry'; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue new file mode 100644 index 00000000000..bd7325f7925 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/graph_component_wrapper.vue @@ -0,0 +1,345 @@ +<script> +import { GlAlert, GlLoadingIcon, GlSprintf } from '@gitlab/ui'; +import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; +import getUserCallouts from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import { __, s__ } from '~/locale'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '~/ci/pipeline_details/constants'; +import getPipelineQuery from '~/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql'; +import { reportToSentry, reportMessageToSentry } from '~/ci/utils'; +import DismissPipelineGraphCallout from './graphql/mutations/dismiss_pipeline_notification.graphql'; +import { + ACTION_FAILURE, + IID_FAILURE, + LAYER_VIEW, + SKIP_RETRY_MODAL_KEY, + STAGE_VIEW, + VIEW_TYPE_KEY, +} from './constants'; +import PipelineGraph from './components/graph_component.vue'; +import GraphViewSelector from './components/graph_view_selector.vue'; +import { + calculatePipelineLayersInfo, + getQueryHeaders, + serializeLoadErrors, + toggleQueryPollingByVisibility, + unwrapPipelineData, +} from './utils'; + +const featureName = 'pipeline_needs_hover_tip'; +const enumFeatureName = featureName.toUpperCase(); + +export default { + name: 'PipelineGraphWrapper', + components: { + GlAlert, + GlLoadingIcon, + GlSprintf, + GraphViewSelector, + LocalStorageSync, + PipelineGraph, + }, + inject: { + graphqlResourceEtag: { + default: '', + }, + metricsPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + pipelineProjectPath: { + default: '', + }, + }, + data() { + return { + alertType: null, + callouts: [], + computedPipelineInfo: null, + currentViewType: STAGE_VIEW, + canRefetchHeaderPipeline: false, + pipeline: null, + skipRetryModal: false, + showAlert: false, + showJobCountWarning: false, + showLinks: false, + }; + }, + errors: { + [ACTION_FAILURE]: { + text: __('An error occurred while performing this action.'), + variant: 'danger', + }, + [DRAW_FAILURE]: { + text: __('An error occurred while drawing job relationship links.'), + variant: 'danger', + }, + [IID_FAILURE]: { + text: __( + 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', + ), + variant: 'info', + }, + [LOAD_FAILURE]: { + text: __('Currently unable to fetch data for this pipeline.'), + variant: 'danger', + }, + [DEFAULT]: { + text: __('An unknown error occurred while loading this graph.'), + variant: 'danger', + }, + }, + apollo: { + callouts: { + query: getUserCallouts, + update(data) { + return data?.currentUser?.callouts?.nodes.map((callout) => callout.featureName) || []; + }, + error(err) { + reportToSentry( + this.$options.name, + `type: callout_load_failure, info: ${serializeLoadErrors(err)}`, + ); + }, + }, + headerPipeline: { + query: getPipelineQuery, + // this query is already being called in pipeline_details_header.vue, which shares the same cache as this component + // the skip here is to prevent sending double network requests on page load + skip() { + return !this.canRefetchHeaderPipeline; + }, + variables() { + return { + fullPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project?.pipeline || {}; + }, + error() { + this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); + }, + }, + pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineDetails, + pollInterval: 10000, + variables() { + return { + projectPath: this.pipelineProjectPath, + iid: this.pipelineIid, + }; + }, + skip() { + return !(this.pipelineProjectPath && this.pipelineIid); + }, + update(data) { + /* + This check prevents the pipeline from being overwritten + when a poll times out and the data returned is empty. + This can be removed once the timeout behavior is updated. + See: https://gitlab.com/gitlab-org/gitlab/-/issues/323213. + */ + + if (!data?.project?.pipeline) { + return this.pipeline; + } + + return unwrapPipelineData(this.pipelineProjectPath, JSON.parse(JSON.stringify(data))); + }, + error(err) { + this.reportFailure({ type: LOAD_FAILURE, skipSentry: true }); + + reportMessageToSentry( + this.$options.name, + `| type: ${LOAD_FAILURE} , info: ${JSON.stringify(err)}`, + { + graphViewType: this.graphViewType, + graphqlResourceEtag: this.graphqlResourceEtag, + metricsPath: this.metricsPath, + projectPath: this.pipelineProjectPath, + pipelineIid: this.pipelineIid, + }, + ); + }, + result({ data, error }) { + const stages = data?.project?.pipeline?.stages?.nodes || []; + + this.showJobCountWarning = stages.some((stage) => { + return stage.groups.nodes.length >= 100; + }); + /* + If there is a successful load after a failure, clear + the failure notification to avoid confusion. + */ + if (!error && this.alertType === LOAD_FAILURE) { + this.hideAlert(); + } + }, + }, + }, + computed: { + alert() { + const { errors } = this.$options; + + return { + text: errors[this.alertType]?.text ?? errors[DEFAULT].text, + variant: errors[this.alertType]?.variant ?? errors[DEFAULT].variant, + }; + }, + configPaths() { + return { + graphqlResourceEtag: this.graphqlResourceEtag, + metricsPath: this.metricsPath, + }; + }, + graphViewType() { + /* This prevents reading view type off the localStorage value if it does not apply. */ + return this.showGraphViewSelector ? this.currentViewType : STAGE_VIEW; + }, + hoverTipPreviouslyDismissed() { + return this.callouts.includes(enumFeatureName); + }, + showLoadingIcon() { + /* + Shows the icon only when the graph is empty, not when it is is + being refetched, for instance, on action completion + */ + return this.$apollo.queries.pipeline.loading && !this.pipeline; + }, + showGraphViewSelector() { + return this.pipeline?.usesNeeds; + }, + }, + mounted() { + if (!this.pipelineIid) { + this.reportFailure({ type: IID_FAILURE, skipSentry: true }); + } + toggleQueryPollingByVisibility(this.$apollo.queries.pipeline); + this.skipRetryModal = Boolean(JSON.parse(localStorage.getItem(SKIP_RETRY_MODAL_KEY))); + }, + errorCaptured(err, _vm, info) { + reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); + }, + methods: { + getPipelineInfo() { + if (this.currentViewType === LAYER_VIEW && !this.computedPipelineInfo) { + this.computedPipelineInfo = calculatePipelineLayersInfo( + this.pipeline, + this.$options.name, + this.metricsPath, + ); + } + + return this.computedPipelineInfo; + }, + handleTipDismissal() { + try { + this.$apollo.mutate({ + mutation: DismissPipelineGraphCallout, + variables: { + featureName, + }, + }); + } catch (err) { + reportToSentry(this.$options.name, `type: callout_dismiss_failure, info: ${err}`); + } + }, + hideAlert() { + this.showAlert = false; + this.alertType = null; + }, + refreshPipelineGraph() { + this.$apollo.queries.pipeline.refetch(); + + // this will update the status in header_component since they share the same cache + this.canRefetchHeaderPipeline = true; + this.$apollo.queries.headerPipeline.refetch(); + }, + // eslint-disable-next-line @gitlab/require-i18n-strings + reportFailure({ type, err = 'No error string passed.', skipSentry = false }) { + this.showAlert = true; + this.alertType = type; + if (!skipSentry) { + reportToSentry(this.$options.name, `type: ${type}, info: ${err}`); + } + }, + updateShowLinksState(val) { + this.showLinks = val; + }, + setSkipRetryModal() { + this.skipRetryModal = true; + }, + updateViewType(type) { + this.currentViewType = type; + }, + }, + i18n: { + jobLimitWarning: { + title: s__('Pipeline|Only the first 100 jobs per stage are displayed'), + desc: s__('Pipeline|To see the remaining jobs, go to the %{boldStart}Jobs%{boldEnd} tab.'), + }, + }, + viewTypeKey: VIEW_TYPE_KEY, +}; +</script> +<template> + <div> + <gl-alert + v-if="showAlert" + :variant="alert.variant" + data-testid="error-alert" + @dismiss="hideAlert" + > + {{ alert.text }} + </gl-alert> + <gl-alert + v-if="showJobCountWarning" + variant="warning" + :dismissible="false" + :title="$options.i18n.jobLimitWarning.title" + data-testid="job-count-warning" + > + <gl-sprintf :message="$options.i18n.jobLimitWarning.desc"> + <template #bold="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </gl-alert> + <local-storage-sync + :storage-key="$options.viewTypeKey" + :value="currentViewType" + as-string + @input="updateViewType" + > + <graph-view-selector + v-if="showGraphViewSelector" + :type="graphViewType" + :show-links="showLinks" + :tip-previously-dismissed="hoverTipPreviouslyDismissed" + @dismissHoverTip="handleTipDismissal" + @updateViewType="updateViewType" + @updateShowLinksState="updateShowLinksState" + /> + </local-storage-sync> + <gl-loading-icon v-if="showLoadingIcon" class="gl-mx-auto gl-my-4" size="lg" /> + <pipeline-graph + v-if="pipeline" + :config-paths="configPaths" + :pipeline="pipeline" + :computed-pipeline-info="getPipelineInfo()" + :skip-retry-modal="skipRetryModal" + :show-links="showLinks" + :view-type="graphViewType" + @error="reportFailure" + @refreshPipelineGraph="refreshPipelineGraph" + @setSkipRetryModal="setSkipRetryModal" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/graphql/mutations/dismiss_pipeline_notification.graphql b/app/assets/javascripts/ci/pipeline_details/graph/graphql/mutations/dismiss_pipeline_notification.graphql new file mode 100644 index 00000000000..e8af1db9592 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/graphql/mutations/dismiss_pipeline_notification.graphql @@ -0,0 +1,5 @@ +mutation DismissPipelineGraphCallout($featureName: String!) { + userCalloutCreate(input: { featureName: $featureName }) { + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/graph/perf_utils.js b/app/assets/javascripts/ci/pipeline_details/graph/perf_utils.js new file mode 100644 index 00000000000..511dcbe6889 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/perf_utils.js @@ -0,0 +1,50 @@ +import { + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + PIPELINES_DETAIL_LINK_DURATION, + PIPELINES_DETAIL_LINKS_TOTAL, + PIPELINES_DETAIL_LINKS_JOB_RATIO, +} from '~/performance/constants'; + +import { performanceMarkAndMeasure } from '~/performance/utils'; +import { reportPerformance } from './api_utils'; + +export const beginPerfMeasure = () => { + performanceMarkAndMeasure({ mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START }); +}; + +export const finishPerfMeasureAndSend = (numLinks, numGroups, metricsPath) => { + performanceMarkAndMeasure({ + mark: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_END, + measures: [ + { + name: PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + start: PIPELINES_DETAIL_LINKS_MARK_CALCULATE_START, + }, + ], + }); + + window.requestAnimationFrame(() => { + const duration = window.performance.getEntriesByName( + PIPELINES_DETAIL_LINKS_MEASURE_CALCULATION, + )[0]?.duration; + + if (!duration) { + return; + } + + const data = { + histograms: [ + { name: PIPELINES_DETAIL_LINK_DURATION, value: duration / 1000 }, + { name: PIPELINES_DETAIL_LINKS_TOTAL, value: numLinks }, + { + name: PIPELINES_DETAIL_LINKS_JOB_RATIO, + value: numLinks / numGroups, + }, + ], + }; + + reportPerformance(metricsPath, data); + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/utils.js b/app/assets/javascripts/ci/pipeline_details/graph/utils.js new file mode 100644 index 00000000000..9a8d6440d4d --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graph/utils.js @@ -0,0 +1,117 @@ +import { isEmpty } from 'lodash'; +import { getIdFromGraphQLId, etagQueryHeaders } from '~/graphql_shared/utils'; +import { reportToSentry } from '~/ci/utils'; + +import { listByLayers } from '~/ci/pipeline_details/utils/parsing_utils'; +import { unwrapStagesWithNeedsAndLookup } from '~/ci/pipeline_details/utils/unwrapping_utils'; +import { beginPerfMeasure, finishPerfMeasureAndSend } from './perf_utils'; + +export { toggleQueryPollingByVisibility } from '~/graphql_shared/utils'; + +const addMulti = (mainPipelineProjectPath, linkedPipeline) => { + return { + ...linkedPipeline, + multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath, + }; +}; + +const calculatePipelineLayersInfo = (pipeline, componentName, metricsPath) => { + const shouldCollectMetrics = Boolean(metricsPath); + + if (shouldCollectMetrics) { + beginPerfMeasure(); + } + + let layers = null; + + try { + layers = listByLayers(pipeline); + + if (shouldCollectMetrics) { + finishPerfMeasureAndSend(layers.linksData.length, layers.numGroups, metricsPath); + } + } catch (err) { + reportToSentry(componentName, err); + } + + return layers; +}; + +const getQueryHeaders = (etagResource) => + etagQueryHeaders('verify/ci/pipeline-graph', etagResource); + +const serializeGqlErr = (gqlError) => { + const { locations = [], message = '', path = [] } = gqlError; + + // eslint-disable-next-line @gitlab/require-i18n-strings + return ` + ${message}. + Locations: ${locations + .flatMap((loc) => Object.entries(loc)) + .flat(2) + .join(' ')}. + Path: ${path.join(', ')}. + `; +}; + +const serializeLoadErrors = (errors) => { + const { gqlError, graphQLErrors, networkError, message } = errors; + + if (!isEmpty(graphQLErrors)) { + return graphQLErrors.map((err) => serializeGqlErr(err)).join('; '); + } + + if (!isEmpty(gqlError)) { + return serializeGqlErr(gqlError); + } + + if (!isEmpty(networkError)) { + return `Network error: ${networkError.message}`; // eslint-disable-line @gitlab/require-i18n-strings + } + + return message; +}; + +const transformId = (linkedPipeline) => { + return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) }; +}; + +const unwrapPipelineData = (mainPipelineProjectPath, data) => { + if (!data?.project?.pipeline) { + return null; + } + + const { pipeline } = data.project; + + const { + upstream, + downstream, + stages: { nodes: stages }, + } = pipeline; + + const { stages: updatedStages, lookup } = unwrapStagesWithNeedsAndLookup(stages); + + return { + ...pipeline, + id: getIdFromGraphQLId(pipeline.id), + stages: updatedStages, + stagesLookup: lookup, + upstream: upstream + ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) + : [], + downstream: downstream + ? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId) + : [], + }; +}; + +const validateConfigPaths = (value) => value.graphqlResourceEtag?.length > 0; + +export { + calculatePipelineLayersInfo, + getQueryHeaders, + serializeGqlErr, + serializeLoadErrors, + unwrapPipelineData, + validateConfigPaths, +}; diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql new file mode 100644 index 00000000000..f93908aeb04 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql @@ -0,0 +1,34 @@ +fragment PipelineStagesConnection on CiConfigStageConnection { + nodes { + name + groups { + nodes { + name + size + jobs { + nodes { + name + script + beforeScript + afterScript + environment + allowFailure + tags + when + only { + refs + } + except { + refs + } + needs { + nodes { + name + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql new file mode 100644 index 00000000000..9afb474cb17 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation cancelPipeline($id: CiPipelineID!) { + pipelineCancel(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql new file mode 100644 index 00000000000..a52cecafcaf --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/delete_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation deletePipeline($id: CiPipelineID!) { + pipelineDestroy(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql new file mode 100644 index 00000000000..2b3b0822653 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql @@ -0,0 +1,5 @@ +mutation retryPipeline($id: CiPipelineID!) { + pipelineRetry(input: { id: $id }) { + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/provider.js b/app/assets/javascripts/ci/pipeline_details/graphql/provider.js new file mode 100644 index 00000000000..ef96b443da8 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/provider.js @@ -0,0 +1,9 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +Vue.use(VueApollo); + +export default new VueApollo({ + defaultClient: createDefaultClient(), +}); diff --git a/app/assets/javascripts/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql b/app/assets/javascripts/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql new file mode 100644 index 00000000000..9257cc7de7b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql @@ -0,0 +1,43 @@ +query getLinkedPipelines($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + path + downstream { + nodes { + id + path + project { + id + name + } + detailedStatus { + id + group + icon + label + } + sourceJob { + id + retried + } + } + } + upstream { + id + path + project { + id + name + } + detailedStatus { + id + group + icon + label + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql new file mode 100644 index 00000000000..eb5643126a2 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/graphql/queries/get_pipeline_header_data.query.graphql @@ -0,0 +1,46 @@ +query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + iid + status + retryable + cancelable + userPermissions { + destroyPipeline + updatePipeline + } + detailedStatus { + id + detailsPath + icon + group + text + } + createdAt + user { + id + name + username + webPath + webUrl + email + avatarUrl + status { + message + emoji + } + } + commit { + id + shortId + title + webPath + } + finishedAt + queuedDuration + duration + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue new file mode 100644 index 00000000000..3a6a655bfa6 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -0,0 +1,625 @@ +<script> +import { + GlAlert, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlModal, + GlModalDirective, + GlSprintf, + GlTooltipDirective, +} from '@gitlab/ui'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL } from '~/ci/constants'; +import { timeIntervalInWords } from '~/lib/utils/datetime_utility'; +import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import { __, s__, sprintf, formatNumber } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; +import cancelPipelineMutation from '../graphql/mutations/cancel_pipeline.mutation.graphql'; +import deletePipelineMutation from '../graphql/mutations/delete_pipeline.mutation.graphql'; +import retryPipelineMutation from '../graphql/mutations/retry_pipeline.mutation.graphql'; +import { getQueryHeaders } from '../graph/utils'; +import getPipelineQuery from './graphql/queries/get_pipeline_header_data.query.graphql'; + +const DELETE_MODAL_ID = 'pipeline-delete-modal'; +const POLL_INTERVAL = 10000; + +export default { + name: 'PipelineDetailsHeader', + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, + pipelineCancel: 'pipelineCancel', + pipelineRetry: 'pipelineRetry', + finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], + components: { + CiBadgeLink, + ClipboardButton, + GlAlert, + GlBadge, + GlButton, + GlIcon, + GlLink, + GlLoadingIcon, + GlModal, + GlSprintf, + TimeAgoTooltip, + }, + directives: { + GlModal: GlModalDirective, + GlTooltip: GlTooltipDirective, + SafeHtml, + }, + i18n: { + scheduleBadgeText: s__('Pipelines|Scheduled'), + scheduleBadgeTooltip: __('This pipeline was created by a schedule'), + childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'), + childBadgeTooltip: __('This is a child pipeline within the parent pipeline'), + latestBadgeText: s__('Pipelines|latest'), + latestBadgeTooltip: __('Latest pipeline for the most recent commit on this branch'), + mergeTrainBadgeText: s__('Pipelines|merge train'), + mergeTrainBadgeTooltip: s__( + 'Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + ), + invalidBadgeText: s__('Pipelines|yaml invalid'), + failedBadgeText: s__('Pipelines|error'), + autoDevopsBadgeText: s__('Pipelines|Auto DevOps'), + autoDevopsBadgeTooltip: __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by Auto DevOps.', + ), + detachedBadgeText: s__('Pipelines|merge request'), + detachedBadgeTooltip: s__( + "Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.", + ), + stuckBadgeText: s__('Pipelines|stuck'), + stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'), + computeMinutesTooltip: s__('Pipelines|Total amount of compute minutes used for the pipeline'), + totalJobsTooltip: s__('Pipelines|Total number of jobs for the pipeline'), + retryPipelineText: __('Retry'), + cancelPipelineText: __('Cancel pipeline'), + deletePipelineText: __('Delete'), + clipboardTooltip: __('Copy commit SHA'), + createdText: s__('Pipelines|created'), + finishedText: s__('Pipelines|finished'), + }, + errorTexts: { + [LOAD_FAILURE]: __('We are currently unable to fetch data for the pipeline header.'), + [POST_FAILURE]: __('An error occurred while making the request.'), + [DELETE_FAILURE]: __('An error occurred while deleting the pipeline.'), + [DEFAULT]: __('An unknown error occurred.'), + }, + modal: { + id: DELETE_MODAL_ID, + title: __('Delete pipeline'), + deleteConfirmationText: __( + 'Are you sure you want to delete this pipeline? Doing so will expire all pipeline caches and delete all related objects, such as builds, logs, artifacts, and triggers. This action cannot be undone.', + ), + actionPrimary: { + text: __('Delete pipeline'), + attributes: { + variant: 'danger', + }, + }, + actionCancel: { + text: __('Cancel'), + }, + }, + inject: { + graphqlResourceEtag: { + default: '', + }, + paths: { + default: {}, + }, + pipelineIid: { + default: '', + }, + }, + props: { + name: { + type: String, + required: false, + default: '', + }, + totalJobs: { + type: String, + required: false, + default: '', + }, + computeMinutes: { + type: String, + required: false, + default: '', + }, + yamlErrors: { + type: String, + required: false, + default: '', + }, + failureReason: { + type: String, + required: false, + default: '', + }, + refText: { + type: String, + required: false, + default: '', + }, + badges: { + type: Object, + required: false, + default: () => {}, + }, + }, + apollo: { + pipeline: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineQuery, + variables() { + return { + fullPath: this.paths.fullProject, + iid: this.pipelineIid, + }; + }, + update(data) { + return data.project.pipeline; + }, + error() { + this.reportFailure(LOAD_FAILURE); + }, + pollInterval: POLL_INTERVAL, + watchLoading(isLoading) { + if (!isLoading) { + // To ensure apollo has updated the cache, + // we only remove the loading state in sync with GraphQL + this.isCanceling = false; + this.isRetrying = false; + } + }, + }, + }, + data() { + return { + pipeline: null, + failureMessages: [], + failureType: null, + isCanceling: false, + isRetrying: false, + isDeleting: false, + }; + }, + computed: { + loading() { + return this.$apollo.queries.pipeline.loading; + }, + hasError() { + return this.failureType; + }, + hasPipelineData() { + return Boolean(this.pipeline); + }, + isLoadingInitialQuery() { + return this.$apollo.queries.pipeline.loading && !this.hasPipelineData; + }, + detailedStatus() { + return this.pipeline?.detailedStatus || {}; + }, + status() { + return this.pipeline?.status; + }, + isFinished() { + return this.$options.finishedStatuses.includes(this.status); + }, + shouldRenderContent() { + return !this.isLoadingInitialQuery && this.hasPipelineData; + }, + failure() { + switch (this.failureType) { + case LOAD_FAILURE: + return { + text: this.$options.errorTexts[LOAD_FAILURE], + variant: 'danger', + }; + case POST_FAILURE: + return { + text: this.$options.errorTexts[POST_FAILURE], + variant: 'danger', + }; + case DELETE_FAILURE: + return { + text: this.$options.errorTexts[DELETE_FAILURE], + variant: 'danger', + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + }; + } + }, + user() { + return this.pipeline?.user; + }, + userId() { + return getIdFromGraphQLId(this.user?.id); + }, + shortId() { + return this.pipeline?.commit?.shortId || ''; + }, + commitPath() { + return this.pipeline?.commit?.webPath || ''; + }, + commitTitle() { + return this.pipeline?.commit?.title || ''; + }, + totalJobsText() { + return sprintf(__('%{jobs} Jobs'), { + jobs: this.totalJobs, + }); + }, + triggeredText() { + return sprintf(__('created pipeline for commit %{linkStart}%{shortId}%{linkEnd}'), { + shortId: this.shortId, + }); + }, + inProgress() { + return this.status === 'RUNNING'; + }, + duration() { + return this.pipeline?.duration || 0; + }, + showDuration() { + return this.duration && this.isFinished; + }, + durationFormatted() { + return timeIntervalInWords(this.duration); + }, + queuedDuration() { + return this.pipeline?.queuedDuration || 0; + }, + inProgressText() { + return sprintf(__('In progress, queued for %{queuedDuration} seconds'), { + queuedDuration: formatNumber(this.queuedDuration), + }); + }, + durationText() { + return sprintf(__('%{duration}, queued for %{queuedDuration} seconds'), { + duration: this.durationFormatted, + queuedDuration: formatNumber(this.queuedDuration), + }); + }, + canRetryPipeline() { + const { retryable, userPermissions } = this.pipeline; + + return retryable && userPermissions.updatePipeline; + }, + canCancelPipeline() { + const { cancelable, userPermissions } = this.pipeline; + + return cancelable && userPermissions.updatePipeline; + }, + showComputeMinutes() { + return this.isFinished && this.computeMinutes !== '0.0'; + }, + }, + methods: { + reportFailure(errorType, errorMessages = []) { + this.failureType = errorType; + this.failureMessages = errorMessages; + }, + async postPipelineAction(name, mutation) { + try { + const { + data: { + [name]: { errors }, + }, + } = await this.$apollo.mutate({ + mutation, + variables: { id: this.pipeline.id }, + }); + + if (errors.length > 0) { + this.isRetrying = false; + + this.reportFailure(POST_FAILURE, errors); + } else { + await this.$apollo.queries.pipeline.refetch(); + if (!this.isFinished) { + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); + } + } + } catch { + this.isRetrying = false; + + this.reportFailure(POST_FAILURE); + } + }, + cancelPipeline() { + this.isCanceling = true; + this.postPipelineAction(this.$options.pipelineCancel, cancelPipelineMutation); + }, + retryPipeline() { + this.isRetrying = true; + this.postPipelineAction(this.$options.pipelineRetry, retryPipelineMutation); + }, + async deletePipeline() { + this.isDeleting = true; + this.$apollo.queries.pipeline.stopPolling(); + + try { + const { + data: { + pipelineDestroy: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: deletePipelineMutation, + variables: { + id: this.pipeline.id, + }, + }); + + if (errors.length > 0) { + this.reportFailure(DELETE_FAILURE, errors); + this.isDeleting = false; + } else { + redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); // eslint-disable-line import/no-deprecated + } + } catch { + this.$apollo.queries.pipeline.startPolling(POLL_INTERVAL); + this.reportFailure(DELETE_FAILURE); + this.isDeleting = false; + } + }, + }, +}; +</script> + +<template> + <div class="gl-my-4" data-testid="pipeline-details-header"> + <gl-alert + v-if="hasError" + class="gl-mb-4" + :title="failure.text" + :variant="failure.variant" + :dismissible="false" + > + <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`"> + {{ failureMessage }} + </div> + </gl-alert> + <gl-loading-icon v-if="loading" class="gl-text-left" size="lg" /> + <div + v-else + class="gl-display-flex gl-justify-content-space-between gl-flex-wrap" + data-qa-selector="pipeline_details_header" + > + <div> + <h3 v-if="name" class="gl-mt-0 gl-mb-3" data-testid="pipeline-name">{{ name }}</h3> + <h3 v-else class="gl-mt-0 gl-mb-3" data-testid="pipeline-commit-title"> + {{ commitTitle }} + </h3> + <div> + <ci-badge-link :status="detailedStatus" /> + <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6"> + <gl-link + v-if="user" + :href="user.webUrl" + class="gl-display-inline-block gl-text-gray-900 gl-font-weight-bold js-user-link" + :data-user-id="userId" + :data-username="user.username" + data-testid="pipeline-user-link" + > + {{ user.name }} + </gl-link> + <gl-sprintf :message="triggeredText"> + <template #link="{ content }"> + <gl-link + :href="commitPath" + class="gl-bg-blue-50 gl-rounded-base gl-px-2 gl-mx-2" + data-testid="commit-link" + target="_blank" + > + {{ content }} + </gl-link> + </template> + </gl-sprintf> + <clipboard-button + :text="shortId" + category="tertiary" + :title="$options.i18n.clipboardTooltip" + size="small" + /> + <span v-if="inProgress" data-testid="pipeline-created-time-ago"> + {{ $options.i18n.createdText }} + <time-ago-tooltip :time="pipeline.createdAt" /> + </span> + <span v-if="isFinished" data-testid="pipeline-finished-time-ago"> + {{ $options.i18n.finishedText }} + <time-ago-tooltip :time="pipeline.finishedAt" /> + </span> + </div> + </div> + <div v-safe-html="refText" class="gl-mb-3" data-testid="pipeline-ref-text"></div> + <div> + <gl-badge + v-if="badges.schedule" + v-gl-tooltip + :title="$options.i18n.scheduleBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.scheduleBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.child" + v-gl-tooltip + :title="$options.i18n.childBadgeTooltip" + variant="info" + size="sm" + > + <gl-sprintf :message="$options.i18n.childBadgeText"> + <template #link="{ content }"> + <gl-link :href="paths.triggeredByPath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </gl-badge> + <gl-badge + v-if="badges.latest" + v-gl-tooltip + :title="$options.i18n.latestBadgeTooltip" + variant="success" + size="sm" + > + {{ $options.i18n.latestBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.mergeTrainPipeline" + v-gl-tooltip + :title="$options.i18n.mergeTrainBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.mergeTrainBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.invalid" + v-gl-tooltip + :title="yamlErrors" + variant="danger" + size="sm" + > + {{ $options.i18n.invalidBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.failed" + v-gl-tooltip + :title="failureReason" + variant="danger" + size="sm" + > + {{ $options.i18n.failedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.autoDevops" + v-gl-tooltip + :title="$options.i18n.autoDevopsBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.autoDevopsBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.detached" + v-gl-tooltip + :title="$options.i18n.detachedBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.detachedBadgeText }} + </gl-badge> + <gl-badge + v-if="badges.stuck" + v-gl-tooltip + :title="$options.i18n.stuckBadgeTooltip" + variant="warning" + size="sm" + > + {{ $options.i18n.stuckBadgeText }} + </gl-badge> + <span + v-gl-tooltip + :title="$options.i18n.totalJobsTooltip" + class="gl-ml-2" + data-testid="total-jobs" + > + <gl-icon name="pipeline" /> + {{ totalJobsText }} + </span> + <span + v-if="showComputeMinutes" + v-gl-tooltip + :title="$options.i18n.computeMinutesTooltip" + class="gl-ml-2" + data-testid="compute-minutes" + > + <gl-icon name="quota" /> + {{ computeMinutes }} + </span> + <span v-if="inProgress" class="gl-ml-2" data-testid="pipeline-running-text"> + <gl-icon name="timer" /> + {{ inProgressText }} + </span> + <span v-if="showDuration" class="gl-ml-2" data-testid="pipeline-duration-text"> + <gl-icon name="timer" /> + {{ durationText }} + </span> + </div> + </div> + <div class="gl-mt-5 gl-lg-mt-0"> + <gl-button + v-if="canRetryPipeline" + v-gl-tooltip + :aria-label="$options.BUTTON_TOOLTIP_RETRY" + :title="$options.BUTTON_TOOLTIP_RETRY" + :loading="isRetrying" + :disabled="isRetrying" + variant="confirm" + data-testid="retry-pipeline" + class="js-retry-button" + @click="retryPipeline()" + > + {{ $options.i18n.retryPipelineText }} + </gl-button> + + <gl-button + v-if="canCancelPipeline" + v-gl-tooltip + :aria-label="$options.BUTTON_TOOLTIP_CANCEL" + :title="$options.BUTTON_TOOLTIP_CANCEL" + :loading="isCanceling" + :disabled="isCanceling" + class="gl-ml-3" + variant="danger" + data-testid="cancel-pipeline" + @click="cancelPipeline()" + > + {{ $options.i18n.cancelPipelineText }} + </gl-button> + + <gl-button + v-if="pipeline.userPermissions.destroyPipeline" + v-gl-modal="$options.modal.id" + :loading="isDeleting" + :disabled="isDeleting" + class="gl-ml-3" + variant="danger" + category="secondary" + data-testid="delete-pipeline" + > + {{ $options.i18n.deletePipelineText }} + </gl-button> + </div> + </div> + <gl-modal + :modal-id="$options.modal.id" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @primary="deletePipeline()" + > + <p> + {{ $options.modal.deleteConfirmationText }} + </p> + </gl-modal> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue new file mode 100644 index 00000000000..4752fbb3e96 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue @@ -0,0 +1,126 @@ +<script> +import { GlButton, GlLink, GlTableLite } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import Tracking from '~/tracking'; +import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import RetryFailedJobMutation from '../graphql/mutations/retry_failed_job.mutation.graphql'; +import { DEFAULT_FIELDS } from '../../constants'; + +export default { + fields: DEFAULT_FIELDS, + retry: __('Retry'), + components: { + CiBadgeLink, + GlButton, + GlLink, + GlTableLite, + }, + directives: { + SafeHtml, + }, + mixins: [Tracking.mixin()], + props: { + failedJobs: { + type: Array, + required: true, + }, + }, + methods: { + async retryJob(id) { + this.track('click_retry', { label: TRACKING_CATEGORIES.failed }); + + try { + const { + data: { + jobRetry: { errors, job }, + }, + } = await this.$apollo.mutate({ + mutation: RetryFailedJobMutation, + variables: { id }, + }); + if (errors.length > 0) { + this.showErrorMessage(); + } else { + redirectTo(job.detailedStatus.detailsPath); // eslint-disable-line import/no-deprecated + } + } catch { + this.showErrorMessage(); + } + }, + canRetryJob(job) { + return job.retryable && job.userPermissions.updateBuild; + }, + showErrorMessage() { + createAlert({ message: s__('Job|There was a problem retrying the failed job.') }); + }, + failureSummary(trace) { + return trace ? trace.htmlSummary : s__('Job|No job log'); + }, + }, +}; +</script> + +<template> + <gl-table-lite + :items="failedJobs" + :fields="$options.fields" + stacked="lg" + fixed + data-testId="tab-failures" + > + <template #table-colgroup="{ fields }"> + <col v-for="field in fields" :key="field.key" :class="field.columnClass" /> + </template> + + <template #cell(name)="{ item }"> + <div + class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" + > + <ci-badge-link :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> + <div class="gl-text-truncate"> + <gl-link + :href="item.detailedStatus.detailsPath" + class="gl-font-weight-bold gl-text-gray-900!" + > + {{ item.name }} + </gl-link> + </div> + </div> + </template> + + <template #cell(stage)="{ item }"> + <div class="gl-text-truncate"> + <span>{{ item.stage.name }}</span> + </div> + </template> + + <template #cell(failureMessage)="{ item }"> + <span data-testid="job-failure-message">{{ item.failureMessage }}</span> + </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="canRetryJob(item)" + icon="retry" + :title="$options.retry" + :aria-label="$options.retry" + @click="retryJob(item.id)" + /> + </template> + + <template #row-details="{ item }"> + <pre + v-if="item.userPermissions.readBuild" + class="gl-w-full gl-text-left gl-border-none" + data-testid="job-log" + > + <code v-safe-html="failureSummary(item.trace)" class="gl-reset-bg gl-p-0" data-testid="job-trace-summary"> + </code> + </pre> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/failed_jobs_app.vue b/app/assets/javascripts/ci/pipeline_details/jobs/failed_jobs_app.vue new file mode 100644 index 00000000000..b946a40e590 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/failed_jobs_app.vue @@ -0,0 +1,65 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import GetFailedJobsQuery from './graphql/queries/get_failed_jobs.query.graphql'; +import FailedJobsTable from './components/failed_jobs_table.vue'; + +export default { + components: { + GlLoadingIcon, + FailedJobsTable, + }, + inject: { + projectPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + apollo: { + failedJobs: { + query: GetFailedJobsQuery, + variables() { + return { + fullPath: this.projectPath, + pipelineIid: this.pipelineIid, + }; + }, + update({ project }) { + const jobNodes = project?.pipeline?.jobs?.nodes || []; + + return jobNodes.map((job) => { + return { + ...job, + // this field is needed for the slot row-details + // on the failed_jobs_table.vue component + _showDetails: true, + }; + }); + }, + error() { + createAlert({ message: s__('Jobs|There was a problem fetching the failed jobs.') }); + }, + }, + }, + data() { + return { + failedJobs: [], + }; + }, + computed: { + loading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="loading" size="lg" class="gl-mt-4" /> + <failed-jobs-table v-else :failed-jobs="failedJobs" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql new file mode 100644 index 00000000000..1955cc9b0ac --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/mutations/retry_failed_job.mutation.graphql @@ -0,0 +1,12 @@ +mutation retryFailedJob($id: CiBuildID!) { + jobRetry(input: { id: $id }) { + job { + id + detailedStatus { + id + detailsPath + } + } + errors + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql new file mode 100644 index 00000000000..c1f994ece24 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_failed_jobs.query.graphql @@ -0,0 +1,45 @@ +query getFailedJobs($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + jobs(statuses: FAILED, retried: false, jobKind: BUILD) { + nodes { + status + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + stage { + id + name + } + name + retryable + userPermissions { + readBuild + updateBuild + } + trace { + htmlSummary + } + failureMessage + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql new file mode 100644 index 00000000000..b0f875160d4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/graphql/queries/get_pipeline_jobs.query.graphql @@ -0,0 +1,71 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getPipelineJobs($fullPath: ID!, $iid: ID!, $after: String) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs(after: $after, first: 20) { + pageInfo { + ...PageInfo + } + nodes { + artifacts { + # eslint-disable-next-line @graphql-eslint/require-id-when-available + nodes { + downloadPath + fileType + } + } + allowFailure + status + scheduledAt + manualJob + triggered + createdByTag + detailedStatus { + id + detailsPath + group + icon + label + text + tooltip + action { + id + buttonTitle + icon + method + path + title + } + } + id + refName + refPath + tags + shortSha + commitPath + stage { + id + name + } + name + duration + finishedAt + coverage + retryable + playable + cancelable + active + stuck + userPermissions { + readBuild + readJobArtifacts + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue new file mode 100644 index 00000000000..81b6152347d --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/jobs/jobs_app.vue @@ -0,0 +1,133 @@ +<script> +import { GlIntersectionObserver, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui'; +import produce from 'immer'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import eventHub from '~/ci/jobs_page/event_hub'; +import JobsTable from '~/ci/jobs_page/components/jobs_table.vue'; +import { JOBS_TAB_FIELDS } from '~/ci/jobs_page/constants'; +import getPipelineJobs from './graphql/queries/get_pipeline_jobs.query.graphql'; + +export default { + fields: JOBS_TAB_FIELDS, + components: { + GlIntersectionObserver, + GlLoadingIcon, + GlSkeletonLoader, + JobsTable, + }, + inject: { + projectPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + apollo: { + jobs: { + query: getPipelineJobs, + variables() { + return { + ...this.queryVariables, + }; + }, + update(data) { + return data.project?.pipeline?.jobs?.nodes || []; + }, + result({ data }) { + if (!data) { + return; + } + this.jobsPageInfo = data.project?.pipeline?.jobs?.pageInfo || {}; + }, + error() { + createAlert({ message: __('An error occurred while fetching the pipelines jobs.') }); + }, + }, + }, + data() { + return { + jobs: [], + jobsPageInfo: {}, + firstLoad: true, + }; + }, + computed: { + queryVariables() { + return { + fullPath: this.projectPath, + iid: this.pipelineIid, + }; + }, + loading() { + return this.$apollo.queries.jobs.loading; + }, + showSkeletonLoader() { + return this.firstLoad && this.loading; + }, + showLoadingSpinner() { + return !this.firstLoad && this.loading; + }, + }, + mounted() { + eventHub.$on('jobActionPerformed', this.handleJobAction); + }, + beforeDestroy() { + eventHub.$off('jobActionPerformed', this.handleJobAction); + }, + methods: { + handleJobAction() { + this.firstLoad = false; + + this.$apollo.queries.jobs.refetch(); + }, + fetchMoreJobs() { + this.firstLoad = false; + + this.$apollo.queries.jobs.fetchMore({ + variables: { + ...this.queryVariables, + after: this.jobsPageInfo.endCursor, + }, + updateQuery: (previousResult, { fetchMoreResult }) => { + const results = produce(fetchMoreResult, (draftData) => { + draftData.project.pipeline.jobs.nodes = [ + ...previousResult.project.pipeline.jobs.nodes, + ...draftData.project.pipeline.jobs.nodes, + ]; + }); + return results; + }, + }); + }, + }, +}; +</script> + +<template> + <div> + <div v-if="showSkeletonLoader" class="gl-mt-5"> + <gl-skeleton-loader :width="1248" :height="73"> + <circle cx="748.031" cy="37.7193" r="15.0307" /> + <circle cx="787.241" cy="37.7193" r="15.0307" /> + <circle cx="827.759" cy="37.7193" r="15.0307" /> + <circle cx="866.969" cy="37.7193" r="15.0307" /> + <circle cx="380" cy="37" r="18" /> + <rect x="432" y="19" width="126.587" height="15" /> + <rect x="432" y="41" width="247" height="15" /> + <rect x="158" y="19" width="86.1" height="15" /> + <rect x="158" y="41" width="168" height="15" /> + <rect x="22" y="19" width="96" height="36" /> + <rect x="924" y="30" width="96" height="15" /> + <rect x="1057" y="20" width="166" height="35" /> + </gl-skeleton-loader> + </div> + + <jobs-table v-else :jobs="jobs" :table-fields="$options.fields" data-testid="jobs-tab-table" /> + + <gl-intersection-observer v-if="jobsPageInfo.hasNextPage" @appear="fetchMoreJobs"> + <gl-loading-icon v-if="showLoadingSpinner" size="lg" /> + </gl-intersection-observer> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js new file mode 100644 index 00000000000..53f755fda37 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/mixins/pipelines_mixin.js @@ -0,0 +1,237 @@ +import Visibility from 'visibilityjs'; +import { createAlert } from '~/alert'; +import eventHub from '~/ci/event_hub'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils'; +import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; +import Poll from '~/lib/utils/poll'; +import { __ } from '~/locale'; +import { validateParams } from '~/ci/pipeline_details/utils'; +import { CANCEL_REQUEST, TOAST_MESSAGE } from '../constants'; + +export default { + data() { + return { + isLoading: false, + hasError: false, + isMakingRequest: false, + updateGraphDropdown: false, + hasMadeRequest: false, + }; + }, + computed: { + shouldRenderPagination() { + return !this.isLoading; + }, + }, + beforeMount() { + this.poll = new Poll({ + resource: this.service, + method: 'getPipelines', + data: this.requestData ? this.requestData : undefined, + successCallback: this.successCallback, + errorCallback: this.errorCallback, + notificationCallback: this.setIsMakingRequest, + }); + + if (!Visibility.hidden()) { + this.isLoading = true; + this.poll.makeRequest(); + } else { + // If tab is not visible we need to make the first request so we don't show the empty + // state without knowing if there are any pipelines + this.fetchPipelines(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + + eventHub.$on('postAction', this.postAction); + eventHub.$on('retryPipeline', this.postAction); + eventHub.$on('clickedDropdown', this.updateTable); + eventHub.$on('updateTable', this.updateTable); + eventHub.$on('runMergeRequestPipeline', this.runMergeRequestPipeline); + }, + beforeDestroy() { + eventHub.$off('postAction', this.postAction); + eventHub.$off('retryPipeline', this.postAction); + eventHub.$off('clickedDropdown', this.updateTable); + eventHub.$off('updateTable', this.updateTable); + eventHub.$off('runMergeRequestPipeline', this.runMergeRequestPipeline); + }, + destroyed() { + this.poll.stop(); + }, + methods: { + updateInternalState(parameters) { + this.poll.stop(); + + const queryString = Object.keys(parameters) + .map((parameter) => { + const value = parameters[parameter]; + // update internal state for UI + this[parameter] = value; + return `${parameter}=${encodeURIComponent(value)}`; + }) + .join('&'); + + // update polling parameters + this.requestData = parameters; + + historyPushState(buildUrlWithCurrentLocation(`?${queryString}`)); + + this.isLoading = true; + }, + /** + * Handles URL and query parameter changes. + * When the user uses the pagination or the tabs, + * - update URL + * - Make API request to the server with new parameters + * - Update the polling function + * - Update the internal state + */ + updateContent(parameters) { + this.updateInternalState(parameters); + + // fetch new data + return this.service + .getPipelines(this.requestData) + .then((response) => { + this.isLoading = false; + this.successCallback(response); + + this.poll.enable({ data: this.requestData, response }); + }) + .catch(() => { + this.isLoading = false; + this.errorCallback(); + + // restart polling + this.poll.restart({ data: this.requestData }); + }); + }, + updateTable() { + // Cancel ongoing request + if (this.isMakingRequest) { + this.service.cancelationSource.cancel(CANCEL_REQUEST); + } + // Stop polling + this.poll.stop(); + // Restarting the poll also makes an initial request + return this.poll.restart(); + }, + fetchPipelines() { + if (!this.isMakingRequest) { + this.isLoading = true; + + this.getPipelines(); + } + }, + getPipelines() { + return this.service + .getPipelines(this.requestData) + .then((response) => this.successCallback(response)) + .catch((error) => this.errorCallback(error)); + }, + setCommonData(pipelines) { + this.store.storePipelines(pipelines); + this.isLoading = false; + this.updateGraphDropdown = true; + this.hasMadeRequest = true; + + // In case the previous polling request returned an error, we need to reset it + if (this.hasError) { + this.hasError = false; + } + }, + errorCallback(error) { + this.hasMadeRequest = true; + this.isLoading = false; + + if (error && error.message && error.message !== CANCEL_REQUEST) { + this.hasError = true; + this.updateGraphDropdown = false; + } + }, + setIsMakingRequest(isMakingRequest) { + this.isMakingRequest = isMakingRequest; + + if (isMakingRequest) { + this.updateGraphDropdown = false; + } + }, + postAction(endpoint) { + this.service + .postAction(endpoint) + .then(() => this.updateTable()) + .catch(() => + createAlert({ + message: __('An error occurred while making the request.'), + }), + ); + }, + + /** + * When the user clicks on the run pipeline button + * we toggle the state of the button to be disabled + * + * Once the post request has finished, we fetch the + * pipelines again to show the most recent data + * + * Once the pipeline has been updated, we toggle back the + * loading state and re-enable the run pipeline button + */ + runMergeRequestPipeline(options) { + this.store.toggleIsRunningPipeline(true); + + this.service + .runMRPipeline(options) + .then(() => { + this.$toast.show(TOAST_MESSAGE); + this.updateTable(); + }) + .catch((e) => { + const unauthorized = e.response.status === HTTP_STATUS_UNAUTHORIZED; + let errorMessage = __( + 'An error occurred while trying to run a new pipeline for this merge request.', + ); + + if (unauthorized) { + errorMessage = __('You do not have permission to run a pipeline on this branch.'); + } + + createAlert({ + message: errorMessage, + primaryButton: { + text: __('Learn more'), + link: helpPagePath('ci/pipelines/merge_request_pipelines.md'), + }, + }); + }) + .finally(() => this.store.toggleIsRunningPipeline(false)); + }, + onChangePage(page) { + /* URLS parameters are strings, we need to parse to match types */ + let params = { + page: Number(page).toString(), + }; + + if (this.scope) { + params.scope = this.scope; + } + + params = this.onChangeWithFilter(params); + + this.updateContent(params); + }, + + onChangeWithFilter(params) { + return { ...params, ...validateParams(this.requestData) }; + }, + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_details_bundle.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_bundle.js new file mode 100644 index 00000000000..da09852a7f4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_bundle.js @@ -0,0 +1,67 @@ +import VueRouter from 'vue-router'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { pipelineTabName } from './constants'; +import { createPipelineDetailsHeaderApp } from './pipeline_details_header'; +import { apolloProvider } from './pipeline_shared_client'; + +const SELECTORS = { + PIPELINE_DETAILS_HEADER: '#js-pipeline-details-header-vue', + PIPELINE_TABS: '#js-pipeline-tabs', +}; + +export default async function initPipelineDetailsBundle() { + const headerSelector = SELECTORS.PIPELINE_DETAILS_HEADER; + + const headerApp = createPipelineDetailsHeaderApp; + + const headerEl = document.querySelector(headerSelector); + + if (headerEl) { + const { dataset: headerDataset } = headerEl; + + try { + headerApp(headerSelector, apolloProvider, headerDataset.graphqlResourceEtag); + } catch { + createAlert({ + message: __('An error occurred while loading a section of this page.'), + }); + } + } + + const tabsEl = document.querySelector(SELECTORS.PIPELINE_TABS); + + if (tabsEl) { + const { dataset } = tabsEl; + const dismissalDescriptions = JSON.parse(dataset.dismissalDescriptions || '{}'); + const { createAppOptions } = await import('ee_else_ce/ci/pipeline_details/pipeline_tabs'); + const { createPipelineTabs } = await import('./pipeline_tabs'); + const { routes } = await import('ee_else_ce/ci/pipeline_details/routes'); + + const securityRoute = routes.find((route) => route.path === '/security'); + if (securityRoute) { + securityRoute.props = { dismissalDescriptions }; + } + + const router = new VueRouter({ + mode: 'history', + base: dataset.pipelinePath, + routes, + }); + + // We handle the shortcut `pipelines/latest` by forwarding the user to the pipeline graph + // tab and changing the route to the correct `pipelines/:id` + if (window.location.pathname.endsWith('latest')) { + router.replace({ name: pipelineTabName }); + } + + try { + const appOptions = createAppOptions(SELECTORS.PIPELINE_TABS, apolloProvider, router); + createPipelineTabs(appOptions); + } catch { + createAlert({ + message: __('An error occurred while loading a section of this page.'), + }); + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js new file mode 100644 index 00000000000..067ec3f305e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js @@ -0,0 +1,75 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import PipelineDetailsHeader from './header/pipeline_details_header.vue'; + +Vue.use(VueApollo); + +export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graphqlResourceEtag) => { + const el = document.querySelector(elSelector); + + if (!el) { + return; + } + + const { + fullPath, + pipelineIid, + pipelinesPath, + name, + totalJobs, + computeMinutes, + yamlErrors, + failureReason, + triggeredByPath, + schedule, + child, + latest, + mergeTrainPipeline, + invalid, + failed, + autoDevops, + detached, + stuck, + refText, + } = el.dataset; + + // eslint-disable-next-line no-new + new Vue({ + el, + name: 'PipelineDetailsHeaderApp', + apolloProvider, + provide: { + paths: { + fullProject: fullPath, + graphqlResourceEtag, + pipelinesPath, + triggeredByPath, + }, + pipelineIid, + }, + render(createElement) { + return createElement(PipelineDetailsHeader, { + props: { + name, + totalJobs, + computeMinutes, + yamlErrors, + failureReason, + refText, + badges: { + schedule: parseBoolean(schedule), + child: parseBoolean(child), + latest: parseBoolean(latest), + mergeTrainPipeline: parseBoolean(mergeTrainPipeline), + invalid: parseBoolean(invalid), + failed: parseBoolean(failed), + autoDevops: parseBoolean(autoDevops), + detached: parseBoolean(detached), + stuck: parseBoolean(stuck), + }, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js new file mode 100644 index 00000000000..c3be487caae --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js @@ -0,0 +1,11 @@ +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +export const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient( + {}, + { + useGet: true, + }, + ), +}); diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_tabs.js b/app/assets/javascripts/ci/pipeline_details/pipeline_tabs.js new file mode 100644 index 00000000000..0ca9a68e70d --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_tabs.js @@ -0,0 +1,116 @@ +import Vue from 'vue'; +import VueRouter from 'vue-router'; +// eslint-disable-next-line no-restricted-imports +import Vuex from 'vuex'; +import VueApollo from 'vue-apollo'; +import { GlToast } from '@gitlab/ui'; +import PipelineTabs from 'ee_else_ce/ci/pipeline_details/tabs/pipeline_tabs.vue'; +import { reportToSentry } from '~/ci/utils'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import createTestReportsStore from './stores/test_reports'; +import { getPipelineDefaultTab } from './utils'; + +Vue.use(GlToast); +Vue.use(VueApollo); +Vue.use(VueRouter); +Vue.use(Vuex); + +export const createAppOptions = (selector, apolloProvider, router) => { + const el = document.querySelector(selector); + + if (!el) return null; + + const { dataset } = el; + const { + canGenerateCodequalityReports, + codequalityReportDownloadPath, + codequalityBlobPath, + codequalityProjectPath, + downloadablePathForReportType, + exposeSecurityDashboard, + exposeLicenseScanningData, + failedJobsCount, + projectPath, + graphqlResourceEtag, + pipelineIid, + pipelineProjectPath, + totalJobCount, + licenseManagementApiUrl, + licenseScanCount, + licensesApiPath, + canManageLicenses, + summaryEndpoint, + suiteEndpoint, + blobPath, + hasTestReport, + emptyDagSvgPath, + emptyStateImagePath, + artifactsExpiredImagePath, + isFullCodequalityReportAvailable, + securityPoliciesPath, + testsCount, + } = dataset; + + const defaultTabValue = getPipelineDefaultTab(window.location.href); + + return { + el, + components: { + PipelineTabs, + }, + apolloProvider, + store: new Vuex.Store({ + modules: { + testReports: createTestReportsStore({ + blobPath, + summaryEndpoint, + suiteEndpoint, + }), + }, + }), + router, + provide: { + canGenerateCodequalityReports: parseBoolean(canGenerateCodequalityReports), + codequalityReportDownloadPath, + codequalityBlobPath, + codequalityProjectPath, + isFullCodequalityReportAvailable: parseBoolean(isFullCodequalityReportAvailable), + projectPath, + defaultTabValue, + downloadablePathForReportType, + exposeSecurityDashboard: parseBoolean(exposeSecurityDashboard), + exposeLicenseScanningData: parseBoolean(exposeLicenseScanningData), + failedJobsCount, + graphqlResourceEtag, + pipelineIid, + pipelineProjectPath, + totalJobCount, + licenseManagementApiUrl, + licenseScanCount, + licensesApiPath, + canManageLicenses: parseBoolean(canManageLicenses), + summaryEndpoint, + suiteEndpoint, + blobPath, + hasTestReport, + emptyDagSvgPath, + emptyStateImagePath, + artifactsExpiredImagePath, + securityPoliciesPath, + testsCount, + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipeline_tabs', `error: ${err}, info: ${info}`); + }, + render(createElement) { + return createElement(PipelineTabs); + }, + }; +}; + +export const createPipelineTabs = (options) => { + if (!options) return; + + // eslint-disable-next-line no-new + new Vue(options); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js new file mode 100644 index 00000000000..d38397e7479 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js @@ -0,0 +1,100 @@ +import { GlToast } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { + parseBoolean, + historyReplaceState, + buildUrlWithCurrentLocation, +} from '~/lib/utils/common_utils'; +import { doesHashExistInUrl } from '~/lib/utils/url_utility'; +import { __ } from '~/locale'; +import Translate from '~/vue_shared/translate'; +import Pipelines from '~/ci/pipelines_page/pipelines.vue'; +import PipelinesStore from './stores/pipelines_store'; + +Vue.use(Translate); +Vue.use(GlToast); +Vue.use(VueApollo); + +const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), +}); + +export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { + const el = document.querySelector(selector); + if (!el) { + return null; + } + + const { + endpoint, + artifactsEndpoint, + artifactsEndpointPlaceholder, + pipelineScheduleUrl, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + newPipelinePath, + pipelineEditorPath, + suggestedCiTemplates, + canCreatePipeline, + hasGitlabCi, + ciLintPath, + resetCachePath, + projectId, + defaultBranchName, + params, + iosRunnersAvailable, + registrationToken, + fullPath, + visibilityPipelineIdType, + } = el.dataset; + + return new Vue({ + el, + apolloProvider, + provide: { + pipelineEditorPath, + artifactsEndpoint, + artifactsEndpointPlaceholder, + suggestedCiTemplates: JSON.parse(suggestedCiTemplates), + iosRunnersAvailable: parseBoolean(iosRunnersAvailable), + fullPath, + manualActionsLimit: 50, + }, + data() { + return { + store: new PipelinesStore(), + }; + }, + created() { + if (doesHashExistInUrl('delete_success')) { + this.$toast.show(__('The pipeline has been deleted')); + historyReplaceState(buildUrlWithCurrentLocation()); + } + }, + render(createElement) { + return createElement(Pipelines, { + props: { + store: this.store, + endpoint, + pipelineScheduleUrl, + emptyStateSvgPath, + errorStateSvgPath, + noPipelinesSvgPath, + newPipelinePath, + canCreatePipeline: parseBoolean(canCreatePipeline), + hasGitlabCi: parseBoolean(hasGitlabCi), + ciLintPath, + resetCachePath, + projectId, + defaultBranchName, + params: JSON.parse(params), + registrationToken, + defaultVisibilityPipelineIdType: visibilityPipelineIdType, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/routes.js b/app/assets/javascripts/ci/pipeline_details/routes.js new file mode 100644 index 00000000000..84207f3ab0c --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/routes.js @@ -0,0 +1,20 @@ +import PipelineGraphWrapper from './graph/graph_component_wrapper.vue'; +import Dag from './dag/dag.vue'; +import FailedJobsApp from './jobs/failed_jobs_app.vue'; +import JobsApp from './jobs/jobs_app.vue'; +import TestReports from './test_reports/test_reports.vue'; +import { + pipelineTabName, + needsTabName, + jobsTabName, + failedJobsTabName, + testReportTabName, +} from './constants'; + +export const routes = [ + { name: pipelineTabName, path: '/', component: PipelineGraphWrapper }, + { name: needsTabName, path: '/dag', component: Dag }, + { name: jobsTabName, path: '/builds', component: JobsApp }, + { name: failedJobsTabName, path: '/failures', component: FailedJobsApp }, + { name: testReportTabName, path: '/test_report', component: TestReports }, +]; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/pipelines_store.js b/app/assets/javascripts/ci/pipeline_details/stores/pipelines_store.js new file mode 100644 index 00000000000..765441560d8 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/pipelines_store.js @@ -0,0 +1,44 @@ +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; + +export default class PipelinesStore { + constructor() { + this.state = {}; + + this.state.pipelines = []; + this.state.count = {}; + this.state.pageInfo = {}; + + // Used in MR Pipelines tab + this.state.isRunningMergeRequestPipeline = false; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + } + + storeCount(count = {}) { + this.state.count = count; + } + + storePagination(pagination = {}) { + let paginationInfo; + + if (Object.keys(pagination).length) { + const normalizedHeaders = normalizeHeaders(pagination); + paginationInfo = parseIntPagination(normalizedHeaders); + } else { + paginationInfo = pagination; + } + + this.state.pageInfo = paginationInfo; + } + + /** + * Toggles the isRunningPipeline flag + * + * @param {Boolean} value + */ + toggleIsRunningPipeline(value = false) { + this.state.isRunningMergeRequestPipeline = value; + } +} diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/actions.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/actions.js new file mode 100644 index 00000000000..1b51bb804d0 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/actions.js @@ -0,0 +1,51 @@ +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; + +export const fetchSummary = ({ state, commit, dispatch }) => { + dispatch('toggleLoading'); + + return axios + .get(state.summaryEndpoint) + .then(({ data }) => { + commit(types.SET_SUMMARY, data); + }) + .catch(() => { + createAlert({ + message: s__('TestReports|There was an error fetching the summary.'), + }); + }) + .finally(() => { + dispatch('toggleLoading'); + }); +}; + +export const fetchTestSuite = ({ state, commit, dispatch }, index) => { + const { hasFullSuite } = state.testReports?.test_suites?.[index] || {}; + // We don't need to fetch the suite if we have the information already + if (hasFullSuite) { + return Promise.resolve(); + } + + dispatch('toggleLoading'); + + const { build_ids = [] } = state.testReports?.test_suites?.[index] || {}; + // Replacing `/:suite_name.json` with the name of the suite. Including the extra characters + // to ensure that we replace exactly the template part of the URL string + + return axios + .get(state.suiteEndpoint, { params: { build_ids } }) + .then(({ data }) => commit(types.SET_SUITE, { suite: data, index })) + .catch((error) => commit(types.SET_SUITE_ERROR, error)) + .finally(() => { + dispatch('toggleLoading'); + }); +}; + +export const setPage = ({ commit }, page) => commit(types.SET_PAGE, page); +export const setSelectedSuiteIndex = ({ commit }, data) => + commit(types.SET_SELECTED_SUITE_INDEX, data); +export const removeSelectedSuiteIndex = ({ commit }) => + commit(types.SET_SELECTED_SUITE_INDEX, null); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_LOADING); diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/constants.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/constants.js new file mode 100644 index 00000000000..83d14e1a109 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/constants.js @@ -0,0 +1 @@ +export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts not found'; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/getters.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/getters.js new file mode 100644 index 00000000000..e6a88bb4175 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/getters.js @@ -0,0 +1,35 @@ +import { addIconStatus, formatFilePath, formattedTime } from './utils'; +import { ARTIFACTS_EXPIRED_ERROR_MESSAGE } from './constants'; + +export const getTestSuites = (state) => { + const { test_suites: testSuites = [] } = state.testReports; + + return testSuites.map((suite) => ({ + ...suite, + formattedTime: formattedTime(suite.total_time), + })); +}; + +export const getSelectedSuite = (state) => + state.testReports?.test_suites?.[state.selectedSuiteIndex] || {}; + +export const getSuiteTests = (state) => { + const { test_cases: testCases = [] } = getSelectedSuite(state); + const { page, perPage } = state.pageInfo; + const start = (page - 1) * perPage; + + return testCases + .map((testCase) => ({ + ...testCase, + classname: testCase.classname || '', + name: testCase.name || '', + filePath: testCase.file ? `${state.blobPath}/${formatFilePath(testCase.file)}` : null, + })) + .map(addIconStatus) + .slice(start, start + perPage); +}; + +export const getSuiteTestCount = (state) => getSelectedSuite(state)?.test_cases?.length || 0; + +export const getSuiteArtifactsExpired = (state) => + state.errorMessage === ARTIFACTS_EXPIRED_ERROR_MESSAGE; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/index.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/index.js new file mode 100644 index 00000000000..f45a53f47b7 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/index.js @@ -0,0 +1,14 @@ +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; +import state from './state'; + +export default (initialState) => { + return { + namespaced: true, + actions, + getters, + mutations, + state: state(initialState), + }; +}; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutation_types.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutation_types.js new file mode 100644 index 00000000000..7651a2f4327 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutation_types.js @@ -0,0 +1,6 @@ +export const SET_PAGE = 'SET_PAGE'; +export const SET_SELECTED_SUITE_INDEX = 'SET_SELECTED_SUITE_INDEX'; +export const SET_SUMMARY = 'SET_SUMMARY'; +export const SET_SUITE = 'SET_SUITE'; +export const SET_SUITE_ERROR = 'SET_SUITE_ERROR'; +export const TOGGLE_LOADING = 'TOGGLE_LOADING'; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutations.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutations.js new file mode 100644 index 00000000000..466574157f5 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/mutations.js @@ -0,0 +1,66 @@ +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import * as types from './mutation_types'; + +export default { + [types.SET_PAGE](state, page) { + Object.assign(state, { + pageInfo: Object.assign(state.pageInfo, { + page, + }), + }); + }, + + [types.SET_SUITE](state, { suite = {}, index = null }) { + state.testReports.test_suites[index] = { ...suite, hasFullSuite: true }; + }, + + [types.SET_SUITE_ERROR](state, error) { + const errorMessage = error.response?.data?.errors; + + if (errorMessage) { + state.errorMessage = errorMessage; + } else { + createAlert({ + message: s__('TestReports|There was an error fetching the test suite.'), + }); + } + }, + + [types.SET_SELECTED_SUITE_INDEX](state, selectedSuiteIndex) { + Object.assign(state, { selectedSuiteIndex }); + }, + + [types.SET_SUMMARY](state, testReports) { + const { total } = testReports; + state.testReports = { + ...testReports, + + /* + TLDR; this is a temporary mapping that will be updated once + test suites have the new data schema + + The backend is in the middle of updating the data schema + to have a `total` object containing the total data values. + The test suites don't have the new schema, but the summary + does. Currently the `test_summary.vue` component takes both + the summary and a test suite depending on what is being viewed. + This is a temporary change to map the new schema to the old until + we can update the schema for the test suites also. + Since test suites is an array, it is easier to just map the summary + to the old schema instead of mapping every test suite to the new. + */ + + total_time: total.time, + total_count: total.count, + success_count: total.success, + failed_count: total.failed, + skipped_count: total.skipped, + error_count: total.error, + }; + }, + + [types.TOGGLE_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, +}; diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/state.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/state.js new file mode 100644 index 00000000000..3ec9418c14e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/state.js @@ -0,0 +1,13 @@ +export default ({ blobPath = '', summaryEndpoint = '', suiteEndpoint = '' }) => ({ + blobPath, + summaryEndpoint, + suiteEndpoint, + testReports: {}, + selectedSuiteIndex: null, + isLoading: false, + errorMessage: null, + pageInfo: { + page: 1, + perPage: 20, + }, +}); diff --git a/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js new file mode 100644 index 00000000000..6b616601bc5 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/stores/test_reports/utils.js @@ -0,0 +1,41 @@ +import { __, sprintf } from '~/locale'; +import { TestStatus } from '../../constants'; + +/** + * Removes `./` from the beginning of a file path so it can be appended onto a blob path + * @param {String} file + * @returns {String} - formatted value + */ +export function formatFilePath(file) { + return file.replace(/^\.?\/*/, ''); +} + +export function iconForTestStatus(status) { + switch (status) { + case TestStatus.SUCCESS: + return 'status_success'; + case TestStatus.FAILED: + return 'status_failed'; + case TestStatus.ERROR: + return 'status_warning'; + case TestStatus.SKIPPED: + return 'status_skipped'; + case TestStatus.UNKNOWN: + default: + return 'status_notfound'; + } +} + +export const formattedTime = (seconds = 0) => { + if (seconds < 1) { + const milliseconds = seconds * 1000; + return sprintf(__('%{milliseconds}ms'), { milliseconds: milliseconds.toFixed(2) }); + } + return sprintf(__('%{seconds}s'), { seconds: seconds.toFixed(2) }); +}; + +export const addIconStatus = (testCase) => ({ + ...testCase, + icon: iconForTestStatus(testCase.status), + formattedTime: formattedTime(testCase.execution_time), +}); diff --git a/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue new file mode 100644 index 00000000000..9783a9b5937 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/tabs/pipeline_tabs.vue @@ -0,0 +1,138 @@ +<script> +import { GlBadge, GlTabs, GlTab } from '@gitlab/ui'; +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import { + failedJobsTabName, + jobsTabName, + needsTabName, + pipelineTabName, + testReportTabName, +} from '../constants'; + +export default { + i18n: { + tabs: { + failedJobsTitle: __('Failed Jobs'), + jobsTitle: __('Jobs'), + needsTitle: __('Needs'), + pipelineTitle: __('Pipeline'), + testsTitle: __('Tests'), + }, + }, + tabNames: { + pipeline: pipelineTabName, + needs: needsTabName, + jobs: jobsTabName, + failures: failedJobsTabName, + tests: testReportTabName, + }, + components: { + GlBadge, + GlTab, + GlTabs, + }, + mixins: [Tracking.mixin()], + inject: ['defaultTabValue', 'failedJobsCount', 'totalJobCount', 'testsCount'], + data() { + return { + activeTab: this.defaultTabValue, + }; + }, + computed: { + showFailedJobsTab() { + return this.failedJobsCount > 0; + }, + }, + watch: { + $route(to) { + this.activeTab = to.name; + }, + }, + methods: { + isActive(tabName) { + return tabName === this.activeTab; + }, + navigateTo(tabName) { + if (this.isActive(tabName)) return; + + this.$router.push({ name: tabName }); + }, + failedJobsTabClick() { + this.track('click_tab', { label: TRACKING_CATEGORIES.failed }); + + this.navigateTo(this.$options.tabNames.failures); + }, + testsTabClick() { + this.track('click_tab', { label: TRACKING_CATEGORIES.tests }); + + this.navigateTo(this.$options.tabNames.tests); + }, + }, +}; +</script> + +<template> + <gl-tabs> + <gl-tab + ref="pipelineTab" + :title="$options.i18n.tabs.pipelineTitle" + :active="isActive($options.tabNames.pipeline)" + data-testid="pipeline-tab" + lazy + @click="navigateTo($options.tabNames.pipeline)" + > + <router-view /> + </gl-tab> + <gl-tab + ref="dagTab" + :title="$options.i18n.tabs.needsTitle" + :active="isActive($options.tabNames.needs)" + data-testid="dag-tab" + lazy + @click="navigateTo($options.tabNames.needs)" + > + <router-view /> + </gl-tab> + <gl-tab + :active="isActive($options.tabNames.jobs)" + data-testid="jobs-tab" + lazy + @click="navigateTo($options.tabNames.jobs)" + > + <template #title> + <span class="gl-mr-2">{{ $options.i18n.tabs.jobsTitle }}</span> + <gl-badge size="sm" data-testid="builds-counter">{{ totalJobCount }}</gl-badge> + </template> + <router-view /> + </gl-tab> + <gl-tab + v-if="showFailedJobsTab" + :title="$options.i18n.tabs.failedJobsTitle" + :active="isActive($options.tabNames.failures)" + data-testid="failed-jobs-tab" + lazy + @click="failedJobsTabClick" + > + <template #title> + <span class="gl-mr-2">{{ $options.i18n.tabs.failedJobsTitle }}</span> + <gl-badge size="sm" data-testid="failed-builds-counter">{{ failedJobsCount }}</gl-badge> + </template> + <router-view /> + </gl-tab> + <gl-tab + :active="isActive($options.tabNames.tests)" + data-testid="tests-tab" + lazy + @click="testsTabClick" + > + <template #title> + <span class="gl-mr-2">{{ $options.i18n.tabs.testsTitle }}</span> + <gl-badge size="sm" data-testid="tests-counter">{{ testsCount }}</gl-badge> + </template> + <router-view /> + </gl-tab> + <slot></slot> + </gl-tabs> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue new file mode 100644 index 00000000000..055b6742ae1 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/empty_state.vue @@ -0,0 +1,61 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { s__ } from '~/locale'; + +export const i18n = { + noTestsButton: s__('TestReports|Learn more about pipeline test reports'), + noTestsDescription: s__('TestReports|No test cases were found in the test report.'), + noTestsTitle: s__('TestReports|There are no tests to display'), + noReportsButton: s__('TestReports|Learn how to upload pipeline test reports'), + noReportsDescription: s__( + 'TestReports|You can configure your job to use unit test reports, and GitLab displays a report here and in the related merge request.', + ), + noReportsTitle: s__('TestReports|There are no test reports for this pipeline'), +}; + +export default { + i18n, + components: { + GlEmptyState, + }, + inject: { + emptyStateImagePath: { + default: '', + }, + hasTestReport: { + default: false, + }, + }, + computed: { + emptyStateText() { + if (this.hasTestReport) { + return { + button: this.$options.i18n.noTestsButton, + description: this.$options.i18n.noTestsDescription, + title: this.$options.i18n.noTestsTitle, + }; + } + return { + button: this.$options.i18n.noReportsButton, + description: this.$options.i18n.noReportsDescription, + title: this.$options.i18n.noReportsTitle, + }; + }, + testReportDocPath() { + return helpPagePath('ci/testing/unit_test_reports'); + }, + }, +}; +</script> + +<template> + <gl-empty-state + :title="emptyStateText.title" + :description="emptyStateText.description" + :svg-path="emptyStateImagePath" + :svg-height="150" + :primary-button-link="testReportDocPath" + :primary-button-text="emptyStateText.button" + /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue new file mode 100644 index 00000000000..3e6faa6b346 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_case_details.vue @@ -0,0 +1,152 @@ +<script> +import { GlBadge, GlFriendlyWrap, GlLink, GlModal, GlTooltipDirective } from '@gitlab/ui'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import { __, n__, s__, sprintf } from '~/locale'; +import CodeBlock from '~/vue_shared/components/code_block.vue'; + +export default { + name: 'TestCaseDetails', + components: { + CodeBlock, + GlBadge, + GlFriendlyWrap, + GlLink, + GlModal, + ModalCopyButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + testCase: { + type: Object, + required: false, + default: () => { + return {}; + }, + }, + visible: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + failureHistoryMessage() { + if (!this.hasRecentFailures) { + return null; + } + + return sprintf( + n__( + 'Reports|Failed %{count} time in %{baseBranch} in the last 14 days', + 'Reports|Failed %{count} times in %{baseBranch} in the last 14 days', + this.recentFailures.count, + ), + { + count: this.recentFailures.count, + baseBranch: this.recentFailures.base_branch, + }, + ); + }, + hasRecentFailures() { + return Boolean(this.recentFailures); + }, + recentFailures() { + return this.testCase.recent_failures; + }, + }, + text: { + name: __('Name'), + file: __('File'), + duration: __('Execution time'), + history: __('History'), + trace: __('System output'), + attachment: s__('TestReports|Attachment'), + copyTestName: s__('TestReports|Copy test name to rerun locally'), + }, + modalCloseButton: { + text: __('Close'), + attributes: { variant: 'confirm' }, + }, +}; +</script> + +<template> + <gl-modal + data-testid="test-case-details-modal" + :modal-id="modalId" + :title="testCase.classname" + :action-primary="$options.modalCloseButton" + :visible="visible" + @hidden="$emit('hidden')" + > + <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.name }}</strong> + <div class="col-sm-9" data-testid="test-case-name"> + {{ testCase.name }} + </div> + </div> + + <div v-if="testCase.file" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.file }}</strong> + <div class="col-sm-9" data-testid="test-case-file"> + <gl-link v-if="testCase.filePath" :href="testCase.filePath"> + {{ testCase.file }} + </gl-link> + <span v-else>{{ testCase.file }}</span> + <modal-copy-button + :title="$options.text.copyTestName" + :text="testCase.file" + :modal-id="modalId" + category="tertiary" + class="gl-ml-1" + /> + </div> + </div> + + <div class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.duration }}</strong> + <div v-if="testCase.formattedTime" class="col-sm-9" data-testid="test-case-duration"> + {{ testCase.formattedTime }} + </div> + <div v-else-if="testCase.execution_time" class="col-sm-9" data-testid="test-case-duration"> + {{ sprintf('%{value} s', { value: testCase.execution_time }) }} + </div> + </div> + + <div v-if="testCase.recent_failures" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.history }}</strong> + <div class="col-sm-9" data-testid="test-case-recent-failures"> + <gl-badge variant="warning">{{ failureHistoryMessage }}</gl-badge> + </div> + </div> + + <div v-if="testCase.attachment_url" class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3"> + <strong class="gl-text-right col-sm-3">{{ $options.text.attachment }}</strong> + <gl-link + class="col-sm-9" + :href="testCase.attachment_url" + target="_blank" + data-testid="test-case-attachment-url" + > + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.attachment_url" /> + </gl-link> + </div> + + <div + v-if="testCase.system_output" + class="gl-display-flex gl-flex-wrap gl-mx-n4 gl-my-3" + data-testid="test-case-trace" + > + <strong class="gl-text-right col-sm-3">{{ $options.text.trace }}</strong> + <div class="col-sm-9"> + <code-block :code="testCase.system_output" /> + </div> + </div> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue new file mode 100644 index 00000000000..a7737d33285 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_reports.vue @@ -0,0 +1,91 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapActions, mapGetters, mapState } from 'vuex'; +import EmptyState from './empty_state.vue'; +import TestSuiteTable from './test_suite_table.vue'; +import TestSummary from './test_summary.vue'; +import TestSummaryTable from './test_summary_table.vue'; + +export default { + name: 'TestReports', + components: { + EmptyState, + GlLoadingIcon, + TestSuiteTable, + TestSummary, + TestSummaryTable, + }, + inject: ['blobPath', 'summaryEndpoint', 'suiteEndpoint'], + computed: { + ...mapState('testReports', ['isLoading', 'selectedSuiteIndex', 'testReports']), + ...mapGetters('testReports', ['getSelectedSuite']), + showSuite() { + return this.selectedSuiteIndex !== null; + }, + showTests() { + const { test_suites: testSuites = [] } = this.testReports; + return testSuites.length > 0; + }, + }, + created() { + this.fetchSummary(); + }, + methods: { + ...mapActions('testReports', [ + 'fetchTestSuite', + 'fetchSummary', + 'setSelectedSuiteIndex', + 'removeSelectedSuiteIndex', + ]), + summaryBackClick() { + this.removeSelectedSuiteIndex(); + }, + summaryTableRowClick(index) { + this.setSelectedSuiteIndex(index); + + // Fetch the test suite when the user clicks to see more details + this.fetchTestSuite(index); + }, + beforeEnterTransition() { + document.documentElement.style.overflowX = 'hidden'; + }, + afterLeaveTransition() { + document.documentElement.style.overflowX = ''; + }, + }, +}; +</script> + +<template> + <div v-if="isLoading"> + <gl-loading-icon size="lg" class="gl-mt-3" /> + </div> + + <div + v-else-if="!isLoading && showTests" + ref="container" + class="gl-relative" + data-testid="tests-detail" + > + <transition + name="slide" + @before-enter="beforeEnterTransition" + @after-leave="afterLeaveTransition" + > + <div v-if="showSuite" key="detail" class="gl-w-full slide-enter-to-element"> + <test-summary :report="getSelectedSuite" show-back @on-back-click="summaryBackClick" /> + + <test-suite-table /> + </div> + + <div v-else key="summary" class="gl-w-full slide-enter-from-element"> + <test-summary :report="testReports" /> + + <test-summary-table @row-click="summaryTableRowClick" /> + </div> + </transition> + </div> + + <empty-state v-else /> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_suite_table.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_suite_table.vue new file mode 100644 index 00000000000..d8af926a796 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_suite_table.vue @@ -0,0 +1,206 @@ +<script> +import { + GlModalDirective, + GlTooltipDirective, + GlFriendlyWrap, + GlIcon, + GlLink, + GlButton, + GlPagination, + GlEmptyState, + GlSprintf, +} from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapGetters, mapActions } from 'vuex'; +import { __, s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import TestCaseDetails from './test_case_details.vue'; + +export const i18n = { + expiredArtifactsTitle: s__('TestReports|Job artifacts are expired'), + expiredArtifactsDescription: s__( + 'TestReports|Test reports require job artifacts but all artifacts are expired. %{linkStart}Learn more%{linkEnd}', + ), +}; + +export default { + name: 'TestsSuiteTable', + components: { + GlIcon, + GlFriendlyWrap, + GlLink, + GlButton, + GlPagination, + GlEmptyState, + GlSprintf, + TestCaseDetails, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + inject: { + artifactsExpiredImagePath: { + default: '', + }, + }, + props: { + heading: { + type: String, + required: false, + default: __('Tests'), + }, + }, + computed: { + ...mapState('testReports', ['pageInfo']), + ...mapGetters('testReports', [ + 'getSuiteTests', + 'getSuiteTestCount', + 'getSuiteArtifactsExpired', + ]), + hasSuites() { + return this.getSuiteTests.length > 0; + }, + }, + methods: { + ...mapActions('testReports', ['setPage']), + }, + wrapSymbols: ['::', '#', '.', '_', '-', '/', '\\'], + i18n, + learnMorePath: helpPagePath('ci/testing/unit_test_reports', { + anchor: 'viewing-unit-test-reports-on-gitlab', + }), +}; +</script> + +<template> + <div> + <div v-if="hasSuites" class="test-reports-table gl-mb-3 js-test-cases-table"> + <div class="row gl-mt-3"> + <div class="col-12"> + <h4>{{ heading }}</h4> + </div> + </div> + <div + role="row" + class="gl-responsive-table-row table-row-header gl-font-weight-bold gl-fill-gray-700" + > + <div role="rowheader" class="table-section section-20"> + {{ __('Suite') }} + </div> + <div role="rowheader" class="table-section section-40"> + {{ __('Name') }} + </div> + <div role="rowheader" class="table-section section-10"> + {{ __('Filename') }} + </div> + <div role="rowheader" class="table-section section-10 text-center"> + {{ __('Status') }} + </div> + <div role="rowheader" class="table-section section-10"> + {{ __('Duration') }} + </div> + <div role="rowheader" class="table-section section-10"> + {{ __('Details') }} + </div> + </div> + + <div + v-for="(testCase, index) in getSuiteTests" + :key="index" + class="gl-responsive-table-row gl-rounded-base gl-align-items-flex-start" + data-testid="test-case-row" + > + <div class="table-section section-20 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> + <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.classname" /> + </div> + </div> + + <div class="table-section section-40 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Name') }}</div> + <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.name" /> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Filename') }}</div> + <div class="table-mobile-content gl-pr-0 gl-sm-pr-2 gl-overflow-wrap-break"> + <gl-link v-if="testCase.file" :href="testCase.filePath" target="_blank"> + <gl-friendly-wrap :symbols="$options.wrapSymbols" :text="testCase.file" /> + </gl-link> + <gl-button + v-if="testCase.file" + v-gl-tooltip + size="small" + category="tertiary" + icon="copy-to-clipboard" + :title="__('Copy to clipboard')" + :data-clipboard-text="testCase.file" + :aria-label="__('Copy to clipboard')" + /> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Status') }}</div> + <div class="table-mobile-content gl-md-display-flex gl-justify-content-center"> + <div class="ci-status-icon" :class="`ci-status-icon-${testCase.status}`"> + <gl-icon :size="24" :name="testCase.icon" /> + </div> + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content gl-pr-0 gl-sm-pr-2"> + {{ testCase.formattedTime }} + </div> + </div> + + <div class="table-section section-10 section-wrap"> + <div role="rowheader" class="table-mobile-header">{{ __('Details') }}</div> + <div class="table-mobile-content"> + <gl-button v-gl-modal-directive="`test-case-details-${index}`">{{ + __('View details') + }}</gl-button> + <test-case-details :modal-id="`test-case-details-${index}`" :test-case="testCase" /> + </div> + </div> + </div> + + <gl-pagination + v-model="pageInfo.page" + class="gl-display-flex gl-justify-content-center" + :per-page="pageInfo.perPage" + :total-items="getSuiteTestCount" + @input="setPage" + /> + </div> + + <div v-else> + <gl-empty-state + v-if="getSuiteArtifactsExpired" + :title="$options.i18n.expiredArtifactsTitle" + :svg-path="artifactsExpiredImagePath" + :svg-height="100" + data-testid="artifacts-expired" + > + <template #description> + <gl-sprintf :message="$options.i18n.expiredArtifactsDescription"> + <template #link="{ content }"> + <gl-link :href="$options.learnMorePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </template> + </gl-empty-state> + <p v-else data-testid="no-test-cases"> + {{ s__('TestReports|There are no test cases to display.') }} + </p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary.vue new file mode 100644 index 00000000000..f6090678ca4 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary.vue @@ -0,0 +1,117 @@ +<script> +import { GlButton, GlProgressBar } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formattedTime } from '../stores/test_reports/utils'; + +export default { + name: 'TestSummary', + components: { + GlButton, + GlProgressBar, + }, + props: { + report: { + type: Object, + required: true, + }, + showBack: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + heading() { + return this.report.name || __('Summary'); + }, + successPercentage() { + // Returns a full number when the decimals equal .00. + // Otherwise returns a float to two decimal points + // Do not include skipped tests as part of the total when doing success calculations. + + const totalCompletedCount = this.report.total_count - this.report.skipped_count; + + if (totalCompletedCount > 0) { + return Number(((this.report.success_count / totalCompletedCount) * 100 || 0).toFixed(2)); + } + return 0; + }, + formattedDuration() { + return formattedTime(this.report.total_time); + }, + progressBarVariant() { + if (this.successPercentage < 33) { + return 'danger'; + } + + if (this.successPercentage >= 33 && this.successPercentage < 66) { + return 'warning'; + } + + if (this.successPercentage >= 66 && this.successPercentage < 90) { + return 'primary'; + } + + return 'success'; + }, + }, + methods: { + onBackClick() { + this.$emit('on-back-click'); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-w-full gl-display-flex gl-mt-3 gl-align-items-center"> + <gl-button + v-if="showBack" + size="small" + class="gl-mr-3 js-back-button" + icon="chevron-lg-left" + :aria-label="__('Go back')" + @click="onBackClick" + /> + + <h4>{{ heading }}</h4> + </div> + + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-w-full gl-mt-3" + > + <div class="gl-display-flex gl-justify-content-space-between gl-flex-basis-half"> + <span class="js-total-tests gl-flex-grow-1">{{ + sprintf(s__('TestReports|%{count} tests'), { count: report.total_count }) + }}</span> + + <span class="js-failed-tests gl-flex-grow-1">{{ + sprintf(s__('TestReports|%{count} failures'), { count: report.failed_count }) + }}</span> + + <span class="js-errored-tests">{{ + sprintf(s__('TestReports|%{count} errors'), { count: report.error_count }) + }}</span> + </div> + <div class="gl-display-flex gl-justify-content-space-between gl-flex-grow-1"> + <div class="gl-display-none gl-md-display-block gl-flex-grow-1"></div> + <span class="js-success-rate gl-flex-grow-1">{{ + sprintf(s__('TestReports|%{rate}%{sign} success rate'), { + rate: successPercentage, + sign: '%', + }) + }}</span> + + <span class="js-duration">{{ formattedDuration }}</span> + </div> + </div> + + <gl-progress-bar + class="gl-mt-5" + :value="successPercentage" + :variant="progressBarVariant" + height="10px" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary_table.vue b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary_table.vue new file mode 100644 index 00000000000..9141947ea04 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/test_reports/test_summary_table.vue @@ -0,0 +1,144 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +// eslint-disable-next-line no-restricted-imports +import { mapGetters } from 'vuex'; +import { s__ } from '~/locale'; + +export default { + name: 'TestsSummaryTable', + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + heading: { + type: String, + required: false, + default: s__('TestReports|Jobs'), + }, + }, + computed: { + ...mapGetters('testReports', ['getTestSuites']), + hasSuites() { + return this.getTestSuites.length > 0; + }, + }, + methods: { + tableRowClick(index) { + this.$emit('row-click', index); + }, + }, +}; +</script> + +<template> + <div> + <div class="gl-mt-5"> + <h4>{{ heading }}</h4> + </div> + + <div v-if="hasSuites" class="js-test-suites-table"> + <div role="row" class="gl-responsive-table-row table-row-header gl-font-weight-bold"> + <div role="rowheader" class="table-section section-25 gl-pl-5"> + {{ __('Job') }} + </div> + <div role="rowheader" class="table-section section-25"> + {{ __('Duration') }} + </div> + <div role="rowheader" class="table-section section-10 gl-text-center"> + {{ __('Failed') }} + </div> + <div role="rowheader" class="table-section section-10 gl-text-center"> + {{ __('Errors') }} + </div> + <div role="rowheader" class="table-section section-10 gl-text-center"> + {{ __('Skipped') }} + </div> + <div role="rowheader" class="table-section section-10 gl-text-center"> + {{ __('Passed') }} + </div> + <div role="rowheader" class="table-section section-10 gl-pr-5 gl-text-right"> + {{ __('Total') }} + </div> + </div> + + <div + v-for="(testSuite, index) in getTestSuites" + :key="index" + role="row" + class="gl-responsive-table-row gl-rounded-base js-suite-row" + :class="{ + 'gl-responsive-table-row-clickable gl-cursor-pointer': !testSuite.suite_error, + }" + @click="tableRowClick(index)" + > + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Suite') }} + </div> + <div class="table-mobile-content underline gl-text-gray-900 gl-pl-5"> + {{ testSuite.name }} + <gl-icon + v-if="testSuite.suite_error" + ref="suiteErrorIcon" + v-gl-tooltip + name="error" + :title="testSuite.suite_error" + class="vertical-align-middle" + /> + </div> + </div> + + <div class="table-section section-25"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Duration') }} + </div> + <div class="table-mobile-content gl-text-left"> + {{ testSuite.formattedTime }} + </div> + </div> + + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Failed') }} + </div> + <div class="table-mobile-content">{{ testSuite.failed_count }}</div> + </div> + + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Errors') }} + </div> + <div class="table-mobile-content">{{ testSuite.error_count }}</div> + </div> + + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Skipped') }} + </div> + <div class="table-mobile-content">{{ testSuite.skipped_count }}</div> + </div> + + <div class="table-section section-10 gl-text-center"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Passed') }} + </div> + <div class="table-mobile-content">{{ testSuite.success_count }}</div> + </div> + + <div class="table-section section-10 gl-text-right pr-md-3"> + <div role="rowheader" class="table-mobile-header gl-font-weight-bold"> + {{ __('Total') }} + </div> + <div class="table-mobile-content">{{ testSuite.total_count }}</div> + </div> + </div> + </div> + + <div v-else> + <p class="js-no-tests-suites">{{ s__('TestReports|There are no test suites to show.') }}</p> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_details/utils/drawing_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/drawing_utils.js new file mode 100644 index 00000000000..d6d9ea94c13 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/utils/drawing_utils.js @@ -0,0 +1,106 @@ +import * as d3 from 'd3'; + +export const createUniqueLinkId = (stageName, jobName) => `${stageName}-${jobName}`; + +/** + * This function expects its first argument data structure + * to be the same shaped as the one generated by `parseData`, + * which contains nodes and links. For each link, + * we find the nodes in the graph, calculate their coordinates and + * trace the lines that represent the needs of each job. + * @param {Object} nodeDict - Resulting object of `parseData` with nodes and links + * @param {String} containerID - Id for the svg the links will be draw in + * @returns {Array} Links that contain all the information about them + */ + +export const generateLinksData = (links, containerID, modifier = '') => { + const containerEl = document.getElementById(containerID); + + return links.map((link) => { + const path = d3.path(); + + const sourceId = link.source; + const targetId = link.target; + + const modifiedSourceId = `${sourceId}${modifier}`; + const modifiedTargetId = `${targetId}${modifier}`; + + const sourceNodeEl = document.getElementById(modifiedSourceId); + const targetNodeEl = document.getElementById(modifiedTargetId); + + const sourceNodeCoordinates = sourceNodeEl.getBoundingClientRect(); + const targetNodeCoordinates = targetNodeEl.getBoundingClientRect(); + const containerCoordinates = containerEl.getBoundingClientRect(); + + // Because we add the svg dynamically and calculate the coordinates + // with plain JS and not D3, we need to account for the fact that + // the coordinates we are getting are absolutes, but we want to draw + // relative to the svg container, which starts at `containerCoordinates(x,y)` + // so we substract these from the total. We also need to remove the padding + // from the total to make sure it's aligned properly. We then make the line + // positioned in the center of the job node by adding half the height + // of the job pill. + const paddingLeft = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-left') || 0, + ); + const paddingTop = parseFloat( + window.getComputedStyle(containerEl, null).getPropertyValue('padding-top') || 0, + ); + + const sourceNodeX = sourceNodeCoordinates.right - containerCoordinates.x - paddingLeft; + const sourceNodeY = + sourceNodeCoordinates.top - + containerCoordinates.y - + paddingTop + + sourceNodeCoordinates.height / 2; + const targetNodeX = targetNodeCoordinates.x - containerCoordinates.x - paddingLeft; + const targetNodeY = + targetNodeCoordinates.y - + containerCoordinates.y - + paddingTop + + sourceNodeCoordinates.height / 2; + + const sourceNodeLeftX = sourceNodeCoordinates.left - containerCoordinates.x - paddingLeft; + + // If the source and target X values are the same, + // it means the nodes are in the same column so we + // want to start the line on the left of the pill + // instead of the right to have a nice curve. + const firstPointCoordinateX = sourceNodeLeftX === targetNodeX ? sourceNodeLeftX : sourceNodeX; + + // First point + path.moveTo(firstPointCoordinateX, sourceNodeY); + + // Make cross-stages lines a straight line all the way + // until we can safely draw the bezier to look nice. + // The adjustment number here is a magic number to make things + // look nice and should change if the padding changes. This goes well + // with gl-px-9 which we translate with 100px here. + const straightLineDestinationX = targetNodeX - 100; + const controlPointX = straightLineDestinationX + (targetNodeX - straightLineDestinationX) / 2; + + if (straightLineDestinationX > firstPointCoordinateX) { + path.lineTo(straightLineDestinationX, sourceNodeY); + } + + // Add bezier curve. The first 4 coordinates are the 2 control + // points to create the curve, and the last one is the end point (x, y). + // We want our control points to be in the middle of the line + path.bezierCurveTo( + controlPointX, + sourceNodeY, + controlPointX, + targetNodeY, + targetNodeX, + targetNodeY, + ); + + return { + ...link, + source: sourceId, + target: targetId, + ref: createUniqueLinkId(sourceId, targetId), + path: path.toString(), + }; + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/utils/index.js b/app/assets/javascripts/ci/pipeline_details/utils/index.js new file mode 100644 index 00000000000..9109342707e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/utils/index.js @@ -0,0 +1,147 @@ +import { pickBy } from 'lodash'; +import { parseUrlPathname } from '~/lib/utils/url_utility'; +import { + NEEDS_PROPERTY, + SUPPORTED_FILTER_PARAMETERS, + validPipelineTabNames, + pipelineTabName, +} from '../constants'; +/* + The following functions are the main engine in transforming the data as + received from the endpoint into the format the d3 graph expects. + + Input is of the form: + [nodes] + nodes: [{category, name, jobs, size}] + category is the stage name + name is a group name; in the case that the group has one job, it is + also the job name + size is the number of parallel jobs + jobs: [{ name, needs}] + job name is either the same as the group name or group x/y + needs: [job-names] + needs is an array of job-name strings + + Output is of the form: + { nodes: [node], links: [link] } + node: { name, category }, + unused info passed through + link: { source, target, value }, with source & target being node names + and value being a constant + + We create nodes in the GraphQL update function, and then here we create the node dictionary, + then create links, and then dedupe the links, so that in the case where + job 4 depends on job 1 and job 2, and job 2 depends on job 1, we show only a single link + from job 1 to job 2 then another from job 2 to job 4. + + CREATE LINKS + nodes.name -> target + nodes.name.needs.each -> source (source is the name of the group, not the parallel job) + 10 -> value (constant) + */ + +export const createNodeDict = (nodes, { needsKey = NEEDS_PROPERTY } = {}) => { + return nodes.reduce((acc, node) => { + const newNode = { + ...node, + needs: node.jobs.map((job) => job[needsKey] || []).flat(), + }; + + if (node.size > 1) { + node.jobs.forEach((job) => { + acc[job.name] = newNode; + }); + } + + acc[node.name] = newNode; + return acc; + }, {}); +}; + +export const validateParams = (params) => { + return pickBy(params, (val, key) => SUPPORTED_FILTER_PARAMETERS.includes(key) && val); +}; + +/** + * This function takes the stages array and transform it + * into a hash where each key is a job name and the job data + * is associated to that key. + * @param {Array} stages + * @returns {Object} - Hash of jobs + */ +export const createJobsHash = (stages = []) => { + const nodes = stages.flatMap(({ groups }) => groups); + return createNodeDict(nodes); +}; + +/** + * This function takes the jobs hash generated by + * `createJobsHash` function and returns an easier + * structure to work with for needs relationship + * where the key is the job name and the value is an + * array of all the needs this job has recursively + * (includes the needs of the needs) + * @param {Object} jobs + * @returns {Object} - Hash of jobs and array of needs + */ +export const generateJobNeedsDict = (jobs = {}) => { + const arrOfJobNames = Object.keys(jobs); + + return arrOfJobNames.reduce((acc, value) => { + const recursiveNeeds = (jobName) => { + if (!jobs[jobName]?.needs) { + return []; + } + + return jobs[jobName].needs + .reduce((needsAcc, job) => { + // It's possible that a needs refer to an optional job + // that is not defined in which case we don't add that entry + if (!jobs[job]) { + return needsAcc; + } + + // If we already have the needs of a job in the accumulator, + // then we use the memoized data instead of the recursive call + // to save some performance. + const newNeeds = acc[job] ?? recursiveNeeds(job); + + // In case it's a parallel job (size > 1), the name of the group + // and the job will be different. This mean we also need to add the group name + // to the list of `needs` to ensure we can properly reference it. + const group = jobs[job]; + if (group.size > 1) { + return [...needsAcc, job, group.name, newNeeds]; + } + + return [...needsAcc, job, newNeeds]; + }, []) + .flat(Infinity); + }; + + // To ensure we don't have duplicates job relationship when 2 jobs + // needed by another both depends on the same jobs, we remove any + // duplicates from the array. + const uniqueValues = Array.from(new Set(recursiveNeeds(value))); + + return { ...acc, [value]: uniqueValues }; + }, {}); +}; + +export const getPipelineDefaultTab = (url) => { + const strippedUrl = parseUrlPathname(url); + const regexp = /\w*$/; + const [tabName] = strippedUrl.match(regexp); + + if (tabName && validPipelineTabNames.includes(tabName)) return tabName; + if (tabName === '') return pipelineTabName; + + return null; +}; + +export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => { + return `${graphqlPath}pipelines/id/${pipelineId}`; +}; + +export const graphqlEtagMergeRequestPipelines = (graphqlPath, mergeRequestId) => { + return `${graphqlPath}merge_requests/id/${mergeRequestId}`; +}; diff --git a/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js new file mode 100644 index 00000000000..0a2a6d16498 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/utils/parsing_utils.js @@ -0,0 +1,182 @@ +import { memoize } from 'lodash'; +import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; +import { createSankey } from '../dag/utils/drawing_utils'; +import { createNodeDict } from './index'; + +/* + A peformant alternative to lodash's isEqual. Because findIndex always finds + the first instance of a match, if the found index is not the first, we know + it is in fact a duplicate. +*/ +const deduplicate = (item, itemIndex, arr) => { + const foundIdx = arr.findIndex((test) => { + return test.source === item.source && test.target === item.target; + }); + + return foundIdx === itemIndex; +}; + +export const makeLinksFromNodes = (nodes, nodeDict, { needsKey = NEEDS_PROPERTY } = {}) => { + const constantLinkValue = 10; // all links are the same weight + return nodes + .map(({ jobs, name: groupName }) => + jobs.map((job) => { + const needs = job[needsKey] || []; + + return needs.reduce((acc, needed) => { + // It's possible that we have an optional job, which + // is being needed by another job. In that scenario, + // the needed job doesn't exist, so we don't want to + // create link for it. + if (nodeDict[needed]?.name) { + acc.push({ + source: nodeDict[needed].name, + target: groupName, + value: constantLinkValue, + }); + } + + return acc; + }, []); + }), + ) + .flat(2); +}; + +export const getAllAncestors = (nodes, nodeDict) => { + const needs = nodes + .map((node) => { + return nodeDict[node]?.needs || ''; + }) + .flat() + .filter(Boolean) + .filter(deduplicate); + + if (needs.length) { + return [...needs, ...getAllAncestors(needs, nodeDict)]; + } + + return []; +}; + +export const filterByAncestors = (links, nodeDict) => + links.filter(({ target, source }) => { + /* + + for every link, check out it's target + for every target, get the target node's needs + then drop the current link source from that list + + call a function to get all ancestors, recursively + is the current link's source in the list of all parents? + then we drop this link + + */ + const targetNode = target; + const targetNodeNeeds = nodeDict[targetNode].needs; + const targetNodeNeedsMinusSource = targetNodeNeeds.filter((need) => need !== source); + const allAncestors = getAllAncestors(targetNodeNeedsMinusSource, nodeDict); + return !allAncestors.includes(source); + }); + +export const parseData = (nodes, { needsKey = NEEDS_PROPERTY } = {}) => { + const nodeDict = createNodeDict(nodes, { needsKey }); + const allLinks = makeLinksFromNodes(nodes, nodeDict, { needsKey }); + const filteredLinks = allLinks.filter(deduplicate); + const links = filterByAncestors(filteredLinks, nodeDict); + + return { nodes, links }; +}; + +/* + The number of nodes in the most populous generation drives the height of the graph. +*/ + +export const getMaxNodes = (nodes) => { + const counts = nodes.reduce((acc, { layer }) => { + if (!acc[layer]) { + acc[layer] = 0; + } + + acc[layer] += 1; + + return acc; + }, []); + + return Math.max(...counts); +}; + +/* + Because we cannot know if a node is part of a relationship until after we + generate the links with createSankey, this function is used after the first call + to find nodes that have no relations. +*/ + +export const removeOrphanNodes = (sankeyfiedNodes) => { + return sankeyfiedNodes.filter((node) => node.sourceLinks.length || node.targetLinks.length); +}; + +/* + This utility accepts unwrapped pipeline data in the format returned from + our standard pipeline GraphQL query and returns a list of names by layer + for the layer view. It can be combined with the stageLookup on the pipeline + to generate columns by layer. +*/ + +export const listByLayers = ({ stages }) => { + const arrayOfJobs = stages.flatMap(({ groups }) => groups); + const parsedData = parseData(arrayOfJobs); + const explicitParsedData = parseData(arrayOfJobs, { needsKey: EXPLICIT_NEEDS_PROPERTY }); + const dataWithLayers = createSankey()(explicitParsedData); + + const pipelineLayers = dataWithLayers.nodes.reduce((acc, { layer, name }) => { + /* sort groups by layer */ + + if (!acc[layer]) { + acc[layer] = []; + } + + acc[layer].push(name); + + return acc; + }, []); + + return { + linksData: parsedData.links, + numGroups: arrayOfJobs.length, + pipelineLayers, + }; +}; + +export const generateColumnsFromLayersListBare = ({ stages, stagesLookup }, pipelineLayers) => { + return pipelineLayers.map((layers, idx) => { + /* + Look up the groups in each layer, + then add each set of layer groups to a stage-like object. + */ + + const groups = layers.map((id) => { + const { stageIdx, groupIdx } = stagesLookup[id]; + return stages[stageIdx]?.groups?.[groupIdx]; + }); + + return { + name: '', + id: `layer-${idx}`, + status: { action: null }, + groups: groups.filter(Boolean), + }; + }); +}; + +export const generateColumnsFromLayersListMemoized = memoize(generateColumnsFromLayersListBare); + +export const keepLatestDownstreamPipelines = (downstreamPipelines = []) => { + return downstreamPipelines.filter((pipeline) => { + if (pipeline.source_job) { + return !pipeline?.source_job?.retried || false; + } + + return !pipeline?.sourceJob?.retried || false; + }); +}; diff --git a/app/assets/javascripts/ci/pipeline_details/utils/unwrapping_utils.js b/app/assets/javascripts/ci/pipeline_details/utils/unwrapping_utils.js new file mode 100644 index 00000000000..7ac813bd527 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_details/utils/unwrapping_utils.js @@ -0,0 +1,73 @@ +import { reportToSentry } from '~/ci/utils'; +import { EXPLICIT_NEEDS_PROPERTY, NEEDS_PROPERTY } from '../constants'; + +const unwrapGroups = (stages) => { + return stages.map((stage, idx) => { + const { + groups: { nodes: groups }, + } = stage; + + /* + Being peformance conscious here means we don't want to spread and copy the + group value just to add one parameter. + */ + /* eslint-disable no-param-reassign */ + const groupsWithStageName = groups.map((group) => { + group.stageName = stage.name; + return group; + }); + /* eslint-enable no-param-reassign */ + + return { node: { ...stage, groups: groupsWithStageName }, lookup: { stageIdx: idx } }; + }); +}; + +const unwrapNodesWithName = (jobArray, prop, field = 'name') => { + if (jobArray.length < 1) { + reportToSentry('unwrapping_utils', 'undefined_job_hunt, array empty from backend'); + } + + return jobArray.map((job) => { + if (job[prop]) { + return { ...job, [prop]: job[prop].nodes.map((item) => item[field] || '') }; + } + return job; + }); +}; + +const unwrapJobWithNeeds = (denodedJobArray) => { + const explicitNeedsUnwrapped = unwrapNodesWithName(denodedJobArray, EXPLICIT_NEEDS_PROPERTY); + return unwrapNodesWithName(explicitNeedsUnwrapped, NEEDS_PROPERTY); +}; + +const unwrapStagesWithNeedsAndLookup = (denodedStages) => { + const unwrappedNestedGroups = unwrapGroups(denodedStages); + + const lookupMap = {}; + + const nodes = unwrappedNestedGroups.map(({ node, lookup }) => { + const { groups } = node; + const groupsWithJobs = groups.map((group, idx) => { + const jobs = unwrapJobWithNeeds(group.jobs.nodes); + + lookupMap[group.name] = { ...lookup, groupIdx: idx }; + return { ...group, jobs }; + }); + + return { ...node, groups: groupsWithJobs }; + }); + + return { stages: nodes, lookup: lookupMap }; +}; + +const unwrapStagesWithNeeds = (denodedStages) => { + return unwrapStagesWithNeedsAndLookup(denodedStages).stages; +}; + +export { + unwrapGroups, + unwrapJobWithNeeds, + unwrapNodesWithName, + unwrapStagesWithNeeds, + unwrapStagesWithNeedsAndLookup, +}; diff --git a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue index 3fe9103c2b3..baf3dbfa090 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/commit/commit_form.vue @@ -108,7 +108,6 @@ export default { <gl-form-group id="commit-group" :label="$options.i18n.commitMessage" - label-cols-sm="2" label-for="commit-message" > <gl-form-textarea @@ -122,7 +121,6 @@ export default { <gl-form-group id="source-branch-group" :label="$options.i18n.sourceBranch" - label-cols-sm="2" label-for="source-branch-field" > <gl-form-input @@ -130,13 +128,12 @@ export default { v-model="sourceBranch" class="gl-font-monospace!" required - data-qa-selector="source_branch_field" + data-testid="source-branch-field" /> <gl-form-checkbox v-if="!isCurrentBranchSourceBranch" v-model="openMergeRequest" data-testid="new-mr-checkbox" - data-qa-selector="new_mr_checkbox" class="gl-mt-3" > <gl-sprintf :message="$options.i18n.startMergeRequest"> @@ -152,7 +149,7 @@ export default { class="js-no-auto-disable gl-mr-3" category="primary" variant="confirm" - data-qa-selector="commit_changes_button" + data-testid="commit-changes-button" :disabled="isSubmitDisabled" :loading="isSaving" > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index bc0cad75c60..8f4d566e7e6 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -89,7 +89,6 @@ export default { icon="external-link" target="_blank" data-testid="template-repo-link" - data-qa-selector="template_repo_link" @click="trackTemplateBrowsing" > {{ $options.i18n.browseTemplates }} @@ -98,7 +97,6 @@ export default { icon="information-o" size="small" data-testid="drawer-toggle" - data-qa-selector="drawer_toggle" @click="toggleHelpDrawer" > {{ $options.i18n.help }} @@ -107,7 +105,6 @@ export default { v-if="glFeatures.ciJobAssistantDrawer" icon="bulb" size="small" - data-qa-selector="job_assistant_drawer_toggle" @click="toggleJobAssistantDrawer" > {{ $options.i18n.jobAssistant }} @@ -117,7 +114,6 @@ export default { icon="bulb" size="small" data-testid="ai-assistant-drawer-toggle" - data-qa-selector="ai_assistant_drawer_toggle" @click="toggleAiAssistantDrawer" > {{ $options.i18n.aiAssistant }} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index a410e4c933c..221a45d4d9a 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -218,7 +218,6 @@ export default { :text="currentBranch" :disabled="!enableBranchSwitcher" icon="branch" - data-qa-selector="branch_selector_button" data-testid="branch-selector" > <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue index 7368d1a3a91..20b42e26f08 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue @@ -52,7 +52,7 @@ export default { }; </script> <template> - <div class="gl-mb-4 gl-display-flex gl-flex-wrap gl-gap-3"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3 gl-mb-4"> <gl-button v-if="showFileTreeToggle" id="file-tree-toggle" diff --git a/app/assets/javascripts/ci/pipeline_editor/components/graph/job_pill.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/job_pill.vue new file mode 100644 index 00000000000..3f1d7255a2b --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/job_pill.vue @@ -0,0 +1,73 @@ +<script> +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; + +export default { + components: { + TooltipOnTruncate, + }, + props: { + jobName: { + type: String, + required: true, + }, + pipelineId: { + type: Number, + required: true, + }, + isHovered: { + type: Boolean, + required: false, + default: false, + }, + isFadedOut: { + type: Boolean, + required: false, + default: false, + }, + handleMouseOver: { + type: Function, + required: false, + default: () => {}, + }, + handleMouseLeave: { + type: Function, + required: false, + default: () => {}, + }, + }, + computed: { + id() { + return `${this.jobName}-${this.pipelineId}`; + }, + jobPillClasses() { + return [ + { 'gl-opacity-3': this.isFadedOut }, + { 'gl-bg-gray-50 gl-inset-border-1-gray-200': this.isHovered }, + ]; + }, + }, + methods: { + onMouseEnter() { + this.$emit('on-mouse-enter', this.jobName); + }, + onMouseLeave() { + this.$emit('on-mouse-leave'); + }, + }, +}; +</script> +<template> + <div class="gl-w-full"> + <tooltip-on-truncate :title="jobName" truncate-target="child" placement="top"> + <div + :id="id" + class="gl-bg-white gl-inset-border-1-gray-100 gl-text-center gl-text-truncate gl-rounded-6 gl-mb-3 gl-px-5 gl-py-3 gl-relative gl-z-index-1 gl-transition-duration-slow gl-transition-timing-function-ease" + :class="jobPillClasses" + @mouseover="onMouseEnter" + @mouseleave="onMouseLeave" + > + {{ jobName }} + </div> + </tooltip-on-truncate> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/graph/pipeline_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/pipeline_graph.vue new file mode 100644 index 00000000000..eb906cfc486 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/pipeline_graph.vue @@ -0,0 +1,169 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { DRAW_FAILURE, DEFAULT } from '~/ci/pipeline_details/constants'; +import LinksLayer from '~/ci/common/private/job_links_layer.vue'; +import JobPill from './job_pill.vue'; +import StageName from './stage_name.vue'; + +export default { + components: { + GlAlert, + JobPill, + LinksLayer, + StageName, + }, + CONTAINER_REF: 'PIPELINE_GRAPH_CONTAINER_REF', + BASE_CONTAINER_ID: 'pipeline-graph-container', + PIPELINE_ID: 0, + STROKE_WIDTH: 2, + errorTexts: { + [DRAW_FAILURE]: __('Could not draw the lines for job relationships'), + [DEFAULT]: __('An unknown error occurred.'), + }, + // The combination of gl-w-full gl-min-w-full and gl-max-w-15 is necessary. + // The max width and the width make sure the ellipsis to work and the min width + // is for when there is less text than the stage column width (which the width 100% does not fix) + jobWrapperClasses: + 'gl-display-flex gl-flex-direction-column gl-align-items-stretch gl-w-full gl-px-8 gl-min-w-full gl-max-w-15', + props: { + pipelineData: { + required: true, + type: Object, + }, + }, + data() { + return { + failureType: null, + highlightedJob: null, + highlightedJobs: [], + measurements: { + height: 0, + width: 0, + }, + }; + }, + computed: { + containerId() { + return `${this.$options.BASE_CONTAINER_ID}-${this.$options.PIPELINE_ID}`; + }, + failure() { + switch (this.failureType) { + case DRAW_FAILURE: + return { + text: this.$options.errorTexts[DRAW_FAILURE], + variant: 'danger', + dismissible: true, + }; + default: + return { + text: this.$options.errorTexts[DEFAULT], + variant: 'danger', + dismissible: true, + }; + } + }, + hasError() { + return this.failureType; + }, + hasHighlightedJob() { + return Boolean(this.highlightedJob); + }, + pipelineStages() { + return this.pipelineData?.stages || []; + }, + }, + watch: { + pipelineData: { + immediate: true, + handler() { + this.$nextTick(() => { + this.computeGraphDimensions(); + }); + }, + }, + }, + methods: { + computeGraphDimensions() { + this.measurements = { + width: this.$refs[this.$options.CONTAINER_REF].scrollWidth, + height: this.$refs[this.$options.CONTAINER_REF].scrollHeight, + }; + }, + isFadedOut(jobName) { + return this.highlightedJobs.length > 1 && !this.isJobHighlighted(jobName); + }, + isJobHighlighted(jobName) { + return this.highlightedJobs.includes(jobName); + }, + onError(error) { + this.reportFailure(error.type); + }, + removeHoveredJob() { + this.highlightedJob = null; + }, + reportFailure(errorType) { + this.failureType = errorType; + }, + resetFailure() { + this.failureType = null; + }, + setHoveredJob(jobName) { + this.highlightedJob = jobName; + }, + updateHighlightedJobs(jobs) { + this.highlightedJobs = jobs; + }, + }, +}; +</script> +<template> + <div> + <gl-alert + v-if="hasError" + :variant="failure.variant" + :dismissible="failure.dismissible" + @dismiss="resetFailure" + > + {{ failure.text }} + </gl-alert> + <div + :id="containerId" + :ref="$options.CONTAINER_REF" + class="gl-bg-gray-10 gl-overflow-auto" + data-testid="graph-container" + > + <links-layer + :pipeline-data="pipelineStages" + :pipeline-id="$options.PIPELINE_ID" + :container-id="containerId" + :container-measurements="measurements" + :highlighted-job="highlightedJob" + @highlightedJobsChange="updateHighlightedJobs" + @error="onError" + > + <div + v-for="(stage, index) in pipelineStages" + :key="`${stage.name}-${index}`" + class="gl-flex-direction-column" + > + <div class="gl-display-flex gl-align-items-center gl-w-full gl-px-9 gl-py-4 gl-mb-5"> + <stage-name :stage-name="stage.name" /> + </div> + <div :class="$options.jobWrapperClasses"> + <job-pill + v-for="group in stage.groups" + :key="group.name" + :job-name="group.name" + :pipeline-id="$options.PIPELINE_ID" + :is-hovered="highlightedJob === group.name" + :is-faded-out="isFadedOut(group.name)" + @on-mouse-enter="setHoveredJob" + @on-mouse-leave="removeHoveredJob" + /> + </div> + </div> + </links-layer> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/graph/stage_name.vue b/app/assets/javascripts/ci/pipeline_editor/components/graph/stage_name.vue new file mode 100644 index 00000000000..600832b7633 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_editor/components/graph/stage_name.vue @@ -0,0 +1,22 @@ +<script> +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; + +export default { + components: { + TooltipOnTruncate, + }, + props: { + stageName: { + type: String, + required: true, + }, + }, +}; +</script> +<template> + <tooltip-on-truncate :title="stageName" truncate-target="child" placement="top"> + <div class="gl-py-2 gl-text-truncate gl-font-weight-bold gl-w-20"> + {{ stageName }} + </div> + </tooltip-on-truncate> +</template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue index ec6ee52b6b2..665ca907ed9 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_header.vue @@ -1,30 +1,11 @@ <script> +import { GlCard } from '@gitlab/ui'; import PipelineStatus from './pipeline_status.vue'; import ValidationSegment from './validation_segment.vue'; -const baseClasses = ['gl-p-5', 'gl-bg-gray-10', 'gl-border-solid', 'gl-border-gray-100']; - -const pipelineStatusClasses = [ - ...baseClasses, - 'gl-border-1', - 'gl-border-b-0!', - 'gl-rounded-top-base', -]; - -const validationSegmentClasses = [...baseClasses, 'gl-border-1', 'gl-rounded-base']; - -const validationSegmentWithPipelineStatusClasses = [ - ...baseClasses, - 'gl-border-1', - 'gl-rounded-bottom-left-base', - 'gl-rounded-bottom-right-base', -]; - export default { - pipelineStatusClasses, - validationSegmentClasses, - validationSegmentWithPipelineStatusClasses, components: { + GlCard, PipelineStatus, ValidationSegment, }, @@ -47,24 +28,19 @@ export default { showPipelineStatus() { return !this.isNewCiConfigFile; }, - // make sure corners are rounded correctly depending on if - // pipeline status is rendered - validationStyling() { - return this.showPipelineStatus - ? this.$options.validationSegmentWithPipelineStatusClasses - : this.$options.validationSegmentClasses; - }, }, }; </script> <template> - <div class="gl-mb-5"> - <pipeline-status - v-if="showPipelineStatus" - :commit-sha="commitSha" - :class="$options.pipelineStatusClasses" - v-on="$listeners" - /> - <validation-segment :class="validationStyling" :ci-config="ciConfigData" /> - </div> + <gl-card + class="gl-new-card gl-mb-3 gl-mt-0" + header-class="gl-new-card-header" + body-class="gl-new-card-body gl-py-4 gl-px-5" + > + <template v-if="showPipelineStatus" #header> + <pipeline-status :commit-sha="commitSha" v-on="$listeners" /> + </template> + + <validation-segment :ci-config="ciConfigData" /> + </gl-card> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue index f1c9770714a..f00098105d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue @@ -1,8 +1,8 @@ <script> import { __ } from '~/locale'; -import { keepLatestDownstreamPipelines } from '~/pipelines/components/parsing_utils'; -import LegacyPipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; -import getLinkedPipelinesQuery from '~/pipelines/graphql/queries/get_linked_pipelines.query.graphql'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; import { PIPELINE_FAILURE } from '../../constants'; export default { diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 3bce50224d9..58b5c0004e0 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -5,13 +5,10 @@ import { truncateSha } from '~/lib/utils/text_utility'; import { s__ } from '~/locale'; import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; -import { - getQueryHeaders, - toggleQueryPollingByVisibility, -} from '~/pipelines/components/graph/utils'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; +import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; const POLL_INTERVAL = 10000; @@ -141,7 +138,9 @@ export default { </script> <template> - <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap"> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-wrap gl-w-full" + > <template v-if="showLoadingState"> <div> <gl-loading-icon class="gl-mr-auto gl-display-inline-block" size="sm" /> @@ -149,20 +148,20 @@ export default { </div> </template> <template v-else-if="hasError"> - <gl-icon class="gl-mr-auto" name="warning-solid" /> - <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + <div> + <gl-icon class="gl-mr-auto" name="warning-solid" /> + <span data-testid="pipeline-error-msg">{{ $options.i18n.fetchError }}</span> + </div> </template> <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" /> + <ci-icon :status="status" :size="16" data-testid="pipeline-status-icon" class="gl-mr-2" /> </a> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> <template #id="{ content }"> - <span data-testid="pipeline-id" data-qa-selector="pipeline_id_content"> - {{ content }}{{ pipelineId }} - </span> + <span data-testid="pipeline-id"> {{ content }}{{ pipelineId }} </span> </template> <template #status>{{ status.text }}</template> <template #commit> @@ -187,9 +186,8 @@ export default { /> <pipeline-editor-mini-graph v-else :pipeline="pipeline" v-on="$listeners" /> <gl-button - class="gl-ml-3" - category="secondary" - variant="confirm" + class="gl-ml-3 gl-align-self-center" + size="small" :href="status.detailsPath" data-testid="pipeline-view-btn" > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue index 8553256f13a..d54ad78b3d3 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/validation_segment.vue @@ -112,8 +112,8 @@ export default { {{ $options.i18n.loading }} </template> <span v-else data-testid="validation-segment"> - <span class="gl-max-w-full" data-qa-selector="validation_message_content"> - <gl-icon :name="icon" /> + <span class="gl-max-w-full"> + <gl-icon :name="icon" class="gl-mr-2" /> <gl-sprintf :message="message"> <template v-if="hasLink" #link="{ content }"> <gl-link :href="helpPath">{{ content }}</gl-link> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js index a604d79259d..32eda355e66 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js +++ b/app/assets/javascripts/ci/pipeline_editor/components/job_assistant_drawer/utils.js @@ -10,7 +10,8 @@ const trimText = (val) => (isString(val) ? trim(val) : val); export const removeEmptyObj = (obj) => { if (isArray(obj)) { return reject(map(obj, removeEmptyObj), isEmptyValue); - } else if (isObject(obj)) { + } + if (isObject(obj)) { return omitBy(mapValues(obj, removeEmptyObj), isEmptyValue); } return obj; @@ -19,7 +20,8 @@ export const removeEmptyObj = (obj) => { export const trimFields = (data) => { if (isArray(data)) { return data.map(trimFields); - } else if (isObject(data)) { + } + if (isObject(data)) { return mapValues(data, trimFields); } return trimText(data); diff --git a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue index a954615ca8a..c7c15cdd76e 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/pipeline_editor_tabs.vue @@ -2,7 +2,7 @@ import { GlAlert, GlLoadingIcon, GlTabs } from '@gitlab/ui'; import CiEditorHeader from 'ee_else_ce/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import { s__, __ } from '~/locale'; -import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import PipelineGraph from '~/ci/pipeline_editor/components/graph/pipeline_graph.vue'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import { CREATE_TAB, @@ -182,7 +182,7 @@ export default { <template> <gl-tabs class="file-editor gl-mb-3" - data-qa-selector="file_editor_container" + data-testid="file-editor-container" :query-param-name="$options.query.TAB_QUERY_PARAM" sync-active-tab-with-query-params > diff --git a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue index efa6a54c638..57694bbcd77 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/popovers/file_tree_popover.vue @@ -42,7 +42,6 @@ export default { target="file-tree-toggle" triggers="manual" placement="right" - data-qa-selector="file_tree_popover" @close-button-clicked="dismissPermanently" > <div v-outside="dismissPermanently" class="gl-font-base gl-mb-3"> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue index 25e4e99bf54..90402a89280 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue @@ -61,8 +61,7 @@ export default { <gl-button variant="confirm" class="gl-mt-3" - data-testid="create_new_ci_button" - data-qa-selector="create_new_ci_button" + data-testid="create-new-ci-button" @click="createEmptyConfigFile" > {{ $options.i18n.btnText }} diff --git a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue index 7583fa7a3b5..617088f303b 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/validate/ci_validate.vue @@ -246,7 +246,6 @@ export default { class="gl-mt-3" :disabled="isInitialCiContentLoading" data-testid="simulate-pipeline-button" - data-qa-selector="simulate_pipeline_button" @click="validateYaml" > {{ $options.i18n.cta }} diff --git a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql index 5354ed7c2d5..3570fc1f008 100644 --- a/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql +++ b/app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.query.graphql @@ -1,4 +1,4 @@ -#import "~/pipelines/graphql/fragments/pipeline_stages_connection.fragment.graphql" +#import "~/ci/pipeline_details/graphql/fragments/pipeline_stages_connection.fragment.graphql" query getCiConfigData($projectPath: ID!, $sha: String, $content: String!) { ciConfig(projectPath: $projectPath, sha: $sha, content: $content) { diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue index de8e5a1a284..49562b0be28 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_app.vue @@ -4,7 +4,7 @@ import { fetchPolicies } from '~/lib/graphql'; import { mergeUrlParams, queryToObject, redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated import { __, s__ } from '~/locale'; -import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; +import { unwrapStagesWithNeeds } from '~/ci/pipeline_details/utils/unwrapping_utils'; import ConfirmUnsavedChangesDialog from './components/ui/confirm_unsaved_changes_dialog.vue'; import PipelineEditorEmptyState from './components/ui/pipeline_editor_empty_state.vue'; @@ -394,7 +394,7 @@ export default { </script> <template> - <div class="gl-mt-4 gl-relative" data-qa-selector="pipeline_editor_app"> + <div class="gl-mt-4 gl-relative"> <gl-loading-icon v-if="isBlobContentLoading" size="lg" class="gl-m-3" /> <pipeline-editor-empty-state v-else-if="showStartScreen || usesExternalConfig" diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/accessors/linked_pipelines_accessors.js b/app/assets/javascripts/ci/pipeline_mini_graph/accessors/linked_pipelines_accessors.js new file mode 100644 index 00000000000..1ca9e35c008 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/accessors/linked_pipelines_accessors.js @@ -0,0 +1,14 @@ +import { get } from 'lodash'; + +export const accessors = { + rest: { + detailedStatus: ['details', 'status'], + }, + graphql: { + detailedStatus: 'detailedStatus', + }, +}; + +export const accessValue = (pipeline, dataMethod, path) => { + return get(pipeline, accessors[dataMethod][path]); +}; diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql new file mode 100644 index 00000000000..64a5964dbeb --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stage.query.graphql @@ -0,0 +1,32 @@ +query getPipelineStage($id: CiStageID!) { + ciPipelineStage(id: $id) { + id + name + detailedStatus { + id + group + icon + } + jobs { + nodes { + id + detailedStatus { + id + action { + id + icon + path + title + } + detailsPath + hasDetails + group + icon + tooltip + } + name + } + } + status + } +} diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql new file mode 100644 index 00000000000..69a29947b16 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/graphql/queries/get_pipeline_stages.query.graphql @@ -0,0 +1,19 @@ +query getPipelineStages($fullPath: ID!, $iid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + stages { + nodes { + id + name + detailedStatus { + id + icon + group + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/job_item.vue new file mode 100644 index 00000000000..7f97097def6 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/job_item.vue @@ -0,0 +1,13 @@ +<script> +export default { + props: { + job: { + type: Object, + required: true, + }, + }, +}; +</script> +<template> + <div>{{ job.id }}</div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue new file mode 100644 index 00000000000..d20d4aec59d --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -0,0 +1,168 @@ +<script> +import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import ActionComponent from '~/ci/common/private/job_action_component.vue'; +import JobNameComponent from '~/ci/common/private/job_name_component.vue'; +import { ICONS } from '~/ci/constants'; +import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; +import { s__, sprintf } from '~/locale'; +import { reportToSentry } from '~/ci/utils'; + +/** + * Renders the badge for the pipeline graph and the job's dropdown. + * + * The following object should be provided as `job`: + * + * { + * "id": 4256, + * "name": "test", + * "status": { + * "icon": "status_success", + * "text": "passed", + * "label": "passed", + * "group": "success", + * "tooltip": "passed", + * "details_path": "/root/ci-mock/builds/4256", + * "action": { + * "icon": "retry", + * "title": "Retry", + * "path": "/root/ci-mock/builds/4256/retry", + * "method": "post" + * } + * } + * } + */ + +export default { + i18n: { + runAgainTooltipText: s__('Pipeline|Run again'), + }, + tooltipConfig: { + boundary: 'viewport', + placement: 'bottom', + customClass: 'gl-pointer-events-none', + }, + components: { + ActionComponent, + JobNameComponent, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [delayedJobMixin], + props: { + job: { + type: Object, + required: true, + }, + cssClassJobName: { + type: String, + required: false, + default: '', + }, + dropdownLength: { + type: Number, + required: false, + default: Infinity, + }, + pipelineId: { + type: Number, + required: false, + default: -1, + }, + }, + computed: { + boundary() { + return this.dropdownLength === 1 ? 'viewport' : 'scrollParent'; + }, + detailsPath() { + return this.status?.details_path; + }, + hasDetails() { + return this.status?.has_details; + }, + status() { + return this.job?.status ? this.job.status : {}; + }, + tooltipText() { + const textBuilder = []; + const { name: jobName } = this.job; + + if (jobName) { + textBuilder.push(jobName); + } + + const { tooltip: statusTooltip } = this.status; + if (jobName && statusTooltip) { + textBuilder.push('-'); + } + + if (statusTooltip) { + if (this.isDelayedJob) { + textBuilder.push(sprintf(statusTooltip, { remainingTime: this.remainingTime })); + } else { + textBuilder.push(statusTooltip); + } + } + + return textBuilder.join(' '); + }, + /** + * Verifies if the provided job has an action path + * + * @return {Boolean} + */ + hasJobAction() { + return Boolean(this.job?.status?.action?.path); + }, + jobActionTooltipText() { + const { group } = this.status; + const { title, icon } = this.status.action; + + return icon === ICONS.RETRY && group === ICONS.SUCCESS + ? this.$options.i18n.runAgainTooltipText + : title; + }, + }, + errorCaptured(err, _vm, info) { + reportToSentry('pipelines_job_item', `pipelines_job_item error: ${err}, info: ${info}`); + }, +}; +</script> +<template> + <div + class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" + data-qa-selector="job_item_container" + > + <gl-link + v-if="hasDetails" + v-gl-tooltip="$options.tooltipConfig" + :href="detailsPath" + :title="tooltipText" + :class="cssClassJobName" + class="js-pipeline-graph-job-link menu-item gl-text-gray-900 gl-active-text-decoration-none gl-focus-text-decoration-none gl-hover-text-decoration-none" + data-testid="job-with-link" + > + <job-name-component :name="job.name" :status="job.status" /> + </gl-link> + + <div + v-else + v-gl-tooltip="{ boundary, placement: 'bottom', customClass: 'gl-pointer-events-none' }" + :title="tooltipText" + :class="cssClassJobName" + class="js-job-component-tooltip non-details-job-component menu-item" + data-testid="job-without-link" + > + <job-name-component :name="job.name" :status="job.status" /> + </div> + + <action-component + v-if="hasJobAction" + :tooltip-text="jobActionTooltipText" + :link="status.action.path" + :action-icon="status.action.icon" + data-qa-selector="action_button" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue new file mode 100644 index 00000000000..8c0e65d1d39 --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue @@ -0,0 +1,98 @@ +<script> +import { GlIcon } from '@gitlab/ui'; +import PipelineStages from './pipeline_stages.vue'; +import LinkedPipelinesMiniList from './linked_pipelines_mini_list.vue'; +/** + * Renders the pipeline mini graph. + * TODO: After all apps have updated to GraphQL data and use the `pipeline_mini_graph.vue` file as an entry, + * we should rename this file to `pipeline_mini_graph_wrapper.vue` + */ +export default { + components: { + GlIcon, + LinkedPipelinesMiniList, + PipelineStages, + }, + arrowStyles: [ + 'arrow-icon gl-display-inline-block gl-mx-1 gl-text-gray-500 gl-vertical-align-middle!', + ], + props: { + downstreamPipelines: { + type: Array, + required: false, + default: () => [], + }, + isGraphql: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pipelinePath: { + type: String, + required: false, + default: '', + }, + stages: { + type: Array, + required: true, + default: () => [], + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + upstreamPipeline: { + type: Object, + required: false, + default: () => {}, + }, + }, + computed: { + hasDownstreamPipelines() { + return Boolean(this.downstreamPipelines.length); + }, + }, +}; +</script> +<template> + <div data-testid="pipeline-mini-graph"> + <linked-pipelines-mini-list + v-if="upstreamPipeline" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + upstreamPipeline, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + data-testid="pipeline-mini-graph-upstream" + /> + <gl-icon + v-if="upstreamPipeline" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="upstream-arrow-icon" + /> + <pipeline-stages + :is-graphql="isGraphql" + :is-merge-train="isMergeTrain" + :stages="stages" + :update-dropdown="updateDropdown" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + <gl-icon + v-if="hasDownstreamPipelines" + :class="$options.arrowStyles" + name="long-arrow" + data-testid="downstream-arrow-icon" + /> + <linked-pipelines-mini-list + v-if="hasDownstreamPipelines" + :triggered="downstreamPipelines" + :pipeline-path="pipelinePath" + data-testid="pipeline-mini-graph-downstream" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue new file mode 100644 index 00000000000..bbe0f1fbefc --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -0,0 +1,176 @@ +<script> +/** + * Renders each stage of the pipeline mini graph. + * + * Given the provided endpoint will make a request to + * fetch the dropdown data when the stage is clicked. + * + * Request is made inside this component to make it reusable between: + * 1. Pipelines main table + * 2. Pipelines table in commit and Merge request views + * 3. Merge request widget + * 4. Commit widget + */ + +import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { createAlert } from '~/alert'; +import eventHub from '~/ci/event_hub'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__, sprintf } from '~/locale'; +import LegacyJobItem from './legacy_job_item.vue'; + +export default { + i18n: { + errorMessage: __('Something went wrong on our end.'), + loadingText: __('Loading...'), + mergeTrainMessage: s__('Pipeline|Merge train pipeline jobs can not be retried'), + stage: __('Stage:'), + viewStageLabel: __('View Stage: %{title}'), + }, + dropdownPopperOpts: { + placement: 'bottom', + positionFixed: true, + }, + components: { + CiIcon, + GlLoadingIcon, + GlDropdown, + LegacyJobItem, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + stage: { + type: Object, + required: true, + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isDropdownOpen: false, + isLoading: false, + dropdownContent: [], + stageName: '', + }; + }, + watch: { + updateDropdown() { + if (this.updateDropdown && this.isDropdownOpen && !this.isLoading) { + this.fetchJobs(); + } + }, + }, + methods: { + onHideDropdown() { + this.isDropdownOpen = false; + }, + onShowDropdown() { + eventHub.$emit('clickedDropdown'); + this.isDropdownOpen = true; + this.isLoading = true; + this.fetchJobs(); + + // used for tracking and is separate from event hub + // to avoid complexity with mixin + this.$emit('miniGraphStageClick'); + }, + fetchJobs() { + axios + .get(this.stage.dropdown_path) + .then(({ data }) => { + this.dropdownContent = data.latest_statuses; + this.stageName = data.name; + this.isLoading = false; + }) + .catch(() => { + this.$refs.dropdown.hide(); + this.isLoading = false; + + createAlert({ + message: this.$options.i18n.errorMessage, + }); + }); + }, + stageAriaLabel(title) { + return sprintf(this.$options.i18n.viewStageLabel, { title }); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + v-gl-tooltip.hover.ds0 + v-gl-tooltip="stage.title" + data-testid="mini-pipeline-graph-dropdown" + variant="link" + :aria-label="stageAriaLabel(stage.title)" + :lazy="true" + :popper-opts="$options.dropdownPopperOpts" + :toggle-class="['gl-rounded-full!']" + menu-class="mini-pipeline-graph-dropdown-menu" + @hide="onHideDropdown" + @show="onShowDropdown" + > + <template #button-content> + <ci-icon + is-borderless + is-interactive + css-classes="gl-rounded-full" + :is-active="isDropdownOpen" + :size="24" + :status="stage.status" + class="gl-display-inline-flex gl-align-items-center gl-border gl-z-index-1" + /> + </template> + <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state"> + <gl-loading-icon size="sm" class="gl-mr-3" /> + <p class="gl-line-height-normal gl-mb-0">{{ $options.i18n.loadingText }}</p> + </div> + <ul + v-else + class="js-builds-dropdown-list scrollable-menu" + data-testid="mini-pipeline-graph-dropdown-menu-list" + > + <div class="gl--flex-center gl-border-b gl-font-weight-bold gl-mb-3 gl-pb-3"> + <span class="gl-mr-1">{{ $options.i18n.stage }}</span> + <span data-testid="pipeline-stage-dropdown-menu-title">{{ stageName }}</span> + </div> + <li v-for="job in dropdownContent" :key="job.id"> + <legacy-job-item + :dropdown-length="dropdownContent.length" + :job="job" + css-class-job-name="pipeline-job-item" + /> + </li> + <template v-if="isMergeTrain"> + <li class="gl-dropdown-divider" role="presentation"> + <hr role="separator" aria-orientation="horizontal" class="dropdown-divider" /> + </li> + <li> + <div + class="gl-display-flex gl-align-items-center" + data-testid="warning-message-merge-trains" + > + <div class="menu-item gl-font-sm gl-text-gray-300!"> + {{ $options.i18n.mergeTrainMessage }} + </div> + </div> + </li> + </template> + </ul> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue new file mode 100644 index 00000000000..8567654a89e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -0,0 +1,132 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import { sprintf, s__ } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import { accessValue } from './accessors/linked_pipelines_accessors'; +/** + * Renders the upstream/downstream portions of the pipeline mini graph. + */ +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + CiIcon, + }, + inject: { + dataMethod: { + default: 'rest', + }, + }, + props: { + triggeredBy: { + type: Array, + required: false, + default: () => [], + }, + triggered: { + type: Array, + required: false, + default: () => [], + }, + pipelinePath: { + type: String, + required: false, + default: '', + }, + }, + data() { + return { + maxRenderedPipelines: 3, + }; + }, + computed: { + // Exactly one of these (triggeredBy and triggered) must be truthy. Never both. Never neither. + isUpstream() { + return Boolean(this.triggeredBy.length) && !this.triggered.length; + }, + isDownstream() { + return !this.triggeredBy.length && Boolean(this.triggered.length); + }, + linkedPipelines() { + return this.isUpstream ? this.triggeredBy : this.triggered; + }, + totalPipelineCount() { + return this.linkedPipelines.length; + }, + linkedPipelinesTrimmed() { + return this.totalPipelineCount > this.maxRenderedPipelines + ? this.linkedPipelines.slice(0, this.maxRenderedPipelines) + : this.linkedPipelines; + }, + shouldRenderCounter() { + return this.isDownstream && this.linkedPipelines.length > this.maxRenderedPipelines; + }, + counterLabel() { + return `+${this.linkedPipelines.length - this.maxRenderedPipelines}`; + }, + counterTooltipText() { + return sprintf(s__('LinkedPipelines|%{counterLabel} more downstream pipelines'), { + counterLabel: this.counterLabel, + }); + }, + }, + methods: { + pipelineTooltipText(pipeline) { + const { label } = accessValue(pipeline, this.dataMethod, 'detailedStatus'); + + return `${pipeline.project.name} - ${label}`; + }, + pipelineStatus(pipeline) { + // detailedStatus is graphQL, details.status is REST + return pipeline?.detailedStatus || pipeline?.details?.status; + }, + triggerButtonClass(pipeline) { + const { group } = accessValue(pipeline, this.dataMethod, 'detailedStatus'); + + return `ci-status-icon-${group}`; + }, + }, +}; +</script> + +<template> + <span + v-if="linkedPipelines" + :class="{ + 'is-upstream': isUpstream, + 'is-downstream': isDownstream, + }" + class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle" + > + <a + v-for="pipeline in linkedPipelinesTrimmed" + :key="pipeline.id" + v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }" + :href="pipeline.path" + :class="triggerButtonClass(pipeline)" + class="linked-pipeline-mini-item gl-display-inline-flex gl-mr-2 gl-my-2 gl-rounded-full gl-vertical-align-middle" + data-testid="linked-pipeline-mini-item" + > + <ci-icon + is-borderless + is-interactive + css-classes="gl-rounded-full" + :size="24" + :status="pipelineStatus(pipeline)" + class="gl-align-items-center gl-border gl-display-inline-flex" + /> + </a> + + <a + v-if="shouldRenderCounter" + v-gl-tooltip="{ title: counterTooltipText }" + :title="counterTooltipText" + :href="pipelinePath" + class="gl-align-items-center gl-bg-gray-50 gl-display-inline-flex gl-font-sm gl-h-6 gl-justify-content-center gl-rounded-pill gl-text-decoration-none gl-text-gray-500 gl-w-7 linked-pipelines-counter linked-pipeline-mini-item" + data-testid="linked-pipeline-counter" + > + {{ counterLabel }} + </a> + </span> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_mini_graph.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_mini_graph.vue new file mode 100644 index 00000000000..358d3dc826e --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_mini_graph.vue @@ -0,0 +1,147 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; +import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants'; +import getLinkedPipelinesQuery from '~/ci/pipeline_details/graphql/queries/get_linked_pipelines.query.graphql'; +import getPipelineStagesQuery from './graphql/queries/get_pipeline_stages.query.graphql'; +import LegacyPipelineMiniGraph from './legacy_pipeline_mini_graph.vue'; + +export default { + i18n: { + linkedPipelinesFetchError: __('There was a problem fetching linked pipelines.'), + stagesFetchError: __('There was a problem fetching the pipeline stages.'), + }, + components: { + GlLoadingIcon, + LegacyPipelineMiniGraph, + }, + props: { + pipelineEtag: { + type: String, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + iid: { + type: String, + required: true, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pollInterval: { + type: Number, + required: false, + default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, + }, + }, + data() { + return { + linkedPipelines: null, + pipelineStages: [], + }; + }, + apollo: { + linkedPipelines: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getLinkedPipelinesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline || this.linkedpipelines; + }, + error() { + createAlert({ message: this.$options.i18n.linkedPipelinesFetchError }); + }, + }, + pipelineStages: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineStagesQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + }; + }, + update({ project }) { + return project?.pipeline?.stages?.nodes || this.pipelineStages; + }, + error() { + createAlert({ message: this.$options.i18n.stagesFetchError }); + }, + }, + }, + computed: { + downstreamPipelines() { + return keepLatestDownstreamPipelines(this.linkedPipelines?.downstream?.nodes); + }, + formattedStages() { + return this.pipelineStages.map((stage) => { + const { name, detailedStatus } = stage; + return { + // TODO: Once we fetch stage by ID with GraphQL, + // this method will change. + // see https://gitlab.com/gitlab-org/gitlab/-/issues/384853 + id: stage.id, + dropdown_path: `${this.pipelinePath}/stage.json?stage=${name}`, + name, + path: `${this.pipelinePath}#${name}`, + status: { + details_path: `${this.pipelinePath}#${name}`, + has_details: detailedStatus?.hasDetails || false, + ...detailedStatus, + }, + title: `${name}: ${detailedStatus?.text || ''}`, + }; + }); + }, + pipelinePath() { + return this.linkedPipelines?.path || ''; + }, + upstreamPipeline() { + return this.linkedPipelines?.upstream; + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.linkedPipelines); + toggleQueryPollingByVisibility(this.$apollo.queries.pipelineStages); + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="$apollo.queries.pipelineStages.loading" /> + <legacy-pipeline-mini-graph + v-else + data-testid="pipeline-mini-graph" + is-graphql + :downstream-pipelines="downstreamPipelines" + :is-merge-train="isMergeTrain" + :pipeline-path="pipelinePath" + :stages="formattedStages" + :upstream-pipeline="upstreamPipeline" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stage.vue new file mode 100644 index 00000000000..747b5d33b1a --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stage.vue @@ -0,0 +1,81 @@ +<script> +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { PIPELINE_MINI_GRAPH_POLL_INTERVAL } from '~/ci/pipeline_details/constants'; +import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; +import getPipelineStageQuery from './graphql/queries/get_pipeline_stage.query.graphql'; +import JobItem from './job_item.vue'; + +export default { + i18n: { + stageFetchError: __('There was a problem fetching the pipeline stage.'), + }, + + components: { + JobItem, + }, + props: { + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pipelineEtag: { + type: String, + required: true, + }, + pollInterval: { + type: Number, + required: false, + default: PIPELINE_MINI_GRAPH_POLL_INTERVAL, + }, + stageId: { + type: String, + required: true, + }, + }, + data() { + return { + jobs: [], + stage: null, + }; + }, + apollo: { + stage: { + context() { + return getQueryHeaders(this.pipelineEtag); + }, + query: getPipelineStageQuery, + pollInterval() { + return this.pollInterval; + }, + variables() { + return { + id: this.stageId, + }; + }, + skip() { + return !this.stageId; + }, + update(data) { + this.jobs = data?.ciPipelineStage?.jobs.nodes; + return data?.ciPipelineStage; + }, + error() { + createAlert({ message: this.$options.i18n.stageFetchError }); + }, + }, + }, + mounted() { + toggleQueryPollingByVisibility(this.$apollo.queries.stage); + }, +}; +</script> + +<template> + <div data-testid="pipeline-stage"> + <ul v-for="job in jobs" :key="job.id"> + <job-item :job="job" /> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stages.vue b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stages.vue new file mode 100644 index 00000000000..f883833f7ea --- /dev/null +++ b/app/assets/javascripts/ci/pipeline_mini_graph/pipeline_stages.vue @@ -0,0 +1,63 @@ +<script> +import PipelineStage from './pipeline_stage.vue'; +import LegacyPipelineStage from './legacy_pipeline_stage.vue'; +/** + * Renders the pipeline stages portion of the pipeline mini graph. + */ +export default { + components: { + LegacyPipelineStage, + PipelineStage, + }, + props: { + stages: { + type: Array, + required: true, + }, + updateDropdown: { + type: Boolean, + required: false, + default: false, + }, + isGraphql: { + type: Boolean, + required: false, + default: false, + }, + isMergeTrain: { + type: Boolean, + required: false, + default: false, + }, + pipelineEtag: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <div class="gl-display-inline gl-vertical-align-middle"> + <div + v-for="stage in stages" + :key="stage.name" + class="pipeline-mini-graph-stage-container dropdown gl-display-inline-flex gl-mr-2 gl-my-2 gl-vertical-align-middle" + > + <pipeline-stage + v-if="isGraphql" + :stage-id="stage.id" + :is-merge-train="isMergeTrain" + :pipeline-etag="pipelineEtag" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + <legacy-pipeline-stage + v-else + :stage="stage" + :update-dropdown="updateDropdown" + :is-merge-train="isMergeTrain" + @miniGraphStageClick="$emit('miniGraphStageClick')" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue index fbdb60f61f1..f701bedc74d 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_empty_state.vue @@ -41,6 +41,7 @@ export default { <template> <gl-empty-state :svg-path="$options.SCHEDULE_MD_SVG_URL" + :svg-height="150" :primary-button-text="$options.i18n.createNew" :primary-button-link="newSchedulePath" > diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index 396ff9808f2..0c3ede47015 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -1,8 +1,7 @@ <script> import { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlFormCheckbox, GlForm, GlFormGroup, @@ -27,8 +26,7 @@ const scheduleId = queryToObject(window.location.search).id; export default { components: { GlButton, - GlDropdown, - GlDropdownItem, + GlCollapsibleListbox, GlForm, GlFormCheckbox, GlFormGroup, @@ -81,7 +79,7 @@ export default { this.description = schedule.description; this.cron = schedule.cron; this.cronTimezone = schedule.cronTimezone; - this.scheduleRef = schedule.ref; + this.scheduleRef = schedule.ref || this.defaultBranch; this.variables = variables.map((variable) => { return { id: variable.id, @@ -144,10 +142,6 @@ export default { revealText: __('Reveal values'), hideText: __('Hide values'), }, - typeOptions: { - [VARIABLE_TYPE]: __('Variable'), - [FILE_TYPE]: __('File'), - }, formElementClasses: 'gl-md-mr-3 gl-mb-3 gl-flex-basis-quarter gl-flex-shrink-0 gl-flex-grow-0', computed: { dropdownTranslations() { @@ -155,7 +149,7 @@ export default { dropdownHeader: this.$options.i18n.targetBranchTag, }; }, - typeOptionsListbox() { + typeOptions() { return [ { text: __('Variable'), @@ -232,9 +226,9 @@ export default { empty: true, }); }, - setVariableAttribute(key, attribute, value) { + setVariableType(typeValue, key) { const variable = this.variables.find((v) => v.key === key); - variable[attribute] = value; + variable.variableType = typeValue; }, removeVariable(index) { this.variables[index].destroy = true; @@ -387,19 +381,15 @@ export default { class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" data-testid="ci-variable-row" > - <gl-dropdown - :text="$options.typeOptions[variable.variableType]" + <gl-collapsible-listbox + :items="typeOptions" + :selected="variable.variableType" :class="$options.formElementClasses" + block data-testid="pipeline-form-ci-variable-type" - > - <gl-dropdown-item - v-for="type in Object.keys($options.typeOptions)" - :key="type" - @click="setVariableAttribute(variable.key, 'variableType', type)" - > - {{ $options.typeOptions[type] }} - </gl-dropdown-item> - </gl-dropdown> + @select="setVariableType($event, variable.key)" + /> + <gl-form-input v-model="variable.key" :placeholder="s__('CiVariables|Input variable key')" @@ -414,7 +404,6 @@ export default { value="*****************" disabled class="gl-mb-3 gl-h-7!" - :style="$options.textAreaStyle" :no-resize="false" data-testid="pipeline-form-ci-variable-hidden-value" /> @@ -424,7 +413,6 @@ export default { v-model="variable.value" :placeholder="s__('CiVariables|Input variable value')" class="gl-mb-3 gl-h-7!" - :style="$options.textAreaStyle" :no-resize="false" data-testid="pipeline-form-ci-variable-value" data-qa-selector="ci_variable_value_field" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue index 08efa794bcc..56d50026f17 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_target.vue @@ -27,10 +27,13 @@ export default { </script> <template> - <div> - <gl-icon :name="iconName" /> + <div data-testid="pipeline-schedule-target"> <span v-if="refPath"> + <gl-icon :name="iconName" /> <gl-link :href="refPath" class="gl-text-gray-900">{{ refDisplay }}</gl-link> </span> + <span v-else> + {{ s__('PipelineSchedules|None') }} + </span> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue b/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue deleted file mode 100644 index b4d84309c5f..00000000000 --- a/app/assets/javascripts/ci/pipeline_schedules/components/take_ownership_modal_legacy.vue +++ /dev/null @@ -1,50 +0,0 @@ -<script> -import { GlModal } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; - -export default { - components: { - GlModal, - }, - props: { - ownershipUrl: { - type: String, - required: true, - }, - }, - modalId: 'pipeline-take-ownership-modal', - i18n: { - takeOwnership: s__('PipelineSchedules|Take ownership'), - ownershipMessage: s__( - 'PipelineSchedules|Only the owner of a pipeline schedule can make changes to it. Do you want to take ownership of this schedule?', - ), - cancelLabel: __('Cancel'), - }, - computed: { - actionCancel() { - return { text: this.$options.i18n.cancelLabel }; - }, - actionPrimary() { - return { - text: this.$options.i18n.takeOwnership, - attributes: { - variant: 'confirm', - category: 'primary', - href: this.ownershipUrl, - 'data-method': 'post', - }, - }; - }, - }, -}; -</script> -<template> - <gl-modal - :modal-id="$options.modalId" - :action-primary="actionPrimary" - :action-cancel="actionCancel" - :title="$options.i18n.takeOwnership" - > - <p>{{ $options.i18n.ownershipMessage }}</p> - </gl-modal> -</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue new file mode 100644 index 00000000000..439dc0eb253 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ci_templates.vue @@ -0,0 +1,106 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { s__, sprintf } from '~/locale'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; +import Tracking from '~/tracking'; + +export default { + components: { + GlAvatar, + GlButton, + }, + mixins: [Tracking.mixin()], + inject: ['pipelineEditorPath', 'suggestedCiTemplates'], + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + filterTemplates: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + const templates = this.suggestedCiTemplates + .filter( + (template) => !this.filterTemplates.length || this.filterTemplates.includes(template.name), + ) + .map(({ name, logo, title }) => { + return { + name: title || name, + description: sprintf(this.$options.i18n.description, { name: title || name }), + isPng: logo.endsWith('png'), + logo, + link: mergeUrlParams({ template: name }, this.pipelineEditorPath), + }; + }); + + return { + templates, + }; + }, + methods: { + trackEvent(template) { + this.track('template_clicked', { + label: template, + }); + }, + logoStyle(template) { + return template.isPng ? { objectFit: 'contain' } : ''; + }, + }, + i18n: { + description: s__( + 'Pipelines|Continuous integration and deployment template to test and deploy your %{name} project.', + ), + cta: s__('Pipelines|Use template'), + }, + AVATAR_SHAPE_OPTION_RECT, +}; +</script> +<template> + <ul class="gl-list-style-none gl-pl-0"> + <li v-for="template in templates" :key="template.name"> + <div + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-border-b-solid gl-border-b-1 gl-border-b-gray-100 gl-pb-3 gl-pt-3" + > + <div class="gl-display-flex gl-flex-direction-row gl-align-items-center"> + <gl-avatar + :alt="template.name" + class="gl-mr-5 gl-bg-white dark-mode-override" + :class="{ 'gl-p-2': template.isPng }" + :style="logoStyle(template)" + :shape="$options.AVATAR_SHAPE_OPTION_RECT" + :size="48" + :src="template.logo" + data-testid="template-logo" + /> + <div class="gl-flex-direction-row"> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800" data-testid="template-name"> + {{ template.name }} + </strong> + </div> + <p class="gl-mb-0 gl-font-sm" data-testid="template-description"> + {{ template.description }} + </p> + </div> + </div> + <gl-button + :disabled="disabled" + category="primary" + variant="confirm" + :href="template.link" + data-testid="template-link" + @click="trackEvent(template.name)" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + </li> + </ul> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue new file mode 100644 index 00000000000..1a2021df9c8 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue @@ -0,0 +1,220 @@ +<script> +import { GlButton, GlCard, GlSprintf, GlLink, GlPopover, GlModalDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { mergeUrlParams, DOCS_URL } from '~/lib/utils/url_utility'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import apolloProvider from '~/ci/pipeline_details/graphql/provider'; +import CiTemplates from './ci_templates.vue'; + +export default { + components: { + GlButton, + GlCard, + GlSprintf, + GlLink, + GlPopover, + RunnerInstructionsModal, + CiTemplates, + }, + directives: { + GlModalDirective, + }, + inject: ['pipelineEditorPath', 'iosRunnersAvailable'], + props: { + registrationToken: { + type: String, + required: false, + default: null, + }, + }, + apolloProvider, + iOSTemplateName: 'iOS-Fastlane', + modalId: 'runner-instructions-modal', + runnerDocsLink: `${DOCS_URL}/runner/install/osx`, + whatElseLink: helpPagePath('ci/index.md'), + i18n: { + title: s__('Pipelines|Get started with GitLab CI/CD'), + subtitle: s__('Pipelines|Building for iOS?'), + explanation: s__("Pipelines|We'll walk you through how to deploy to iOS in two easy steps."), + runnerSetupTitle: s__('Pipelines|1. Set up a runner'), + runnerSetupButton: s__('Pipelines|Set up a runner'), + runnerSetupBodyUnfinished: s__( + 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline.', + ), + runnerSetupBodyFinished: s__( + 'Pipelines|You have runners available to run your job now. No need to do anything else.', + ), + runnerSetupPopoverTitle: s__( + "Pipelines|Let's get that runner set up! %{emojiStart}tada%{emojiEnd}", + ), + runnerSetupPopoverBodyLine1: s__( + 'Pipelines|Follow these instructions to install GitLab Runner on macOS.', + ), + runnerSetupPopoverBodyLine2: s__( + 'Pipelines|Need more information to set up your runner? %{linkStart}Check out our documentation%{linkEnd}.', + ), + configurePipelineTitle: s__('Pipelines|2. Configure deployment pipeline'), + configurePipelineBody: s__("Pipelines|We'll guide you through a simple pipeline set-up."), + configurePipelineButton: s__('Pipelines|Configure pipeline'), + noWalkthroughTitle: s__("Pipelines|Don't need a guide? Jump in right away with a template."), + noWalkthroughExplanation: s__('Pipelines|Based on your project, we recommend this template:'), + notBuildingForIos: s__( + "Pipelines|Not building for iOS or not what you're looking for? %{linkStart}See what else%{linkEnd} GitLab CI/CD has to offer.", + ), + }, + data() { + return { + isModalShown: false, + isPopoverShown: false, + isRunnerSetupFinished: this.iosRunnersAvailable, + popoverTarget: `${this.$options.modalId}___BV_modal_content_`, + configurePipelineLink: mergeUrlParams( + { template: this.$options.iOSTemplateName }, + this.pipelineEditorPath, + ), + }; + }, + computed: { + runnerSetupBodyText() { + return this.iosRunnersAvailable + ? this.$options.i18n.runnerSetupBodyFinished + : this.$options.i18n.runnerSetupBodyUnfinished; + }, + }, + methods: { + showModal() { + this.isModalShown = true; + }, + hideModal() { + this.togglePopover(); + this.isRunnerSetupFinished = true; + }, + togglePopover() { + this.isPopoverShown = !this.isPopoverShown; + }, + }, +}; +</script> + +<template> + <div> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.title }}</h2> + <h3 class="gl-font-lg gl-text-gray-900 gl-mt-1">{{ $options.i18n.subtitle }}</h3> + <p>{{ $options.i18n.explanation }}</p> + + <div class="gl-lg-display-flex"> + <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> + <gl-card body-class="gl-display-flex gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" + > + <div> + <div class="gl-py-5"> + <gl-emoji + v-show="isRunnerSetupFinished" + class="gl-font-size-h2-xl" + data-name="white_check_mark" + data-testid="runner-setup-marked-completed" + /> + <gl-emoji + v-show="!isRunnerSetupFinished" + class="gl-font-size-h2-xl" + data-name="tools" + data-testid="runner-setup-marked-todo" + /> + </div> + <span class="gl-text-gray-800 gl-font-weight-bold"> + {{ $options.i18n.runnerSetupTitle }} + </span> + <p class="gl-font-sm gl-mt-3">{{ runnerSetupBodyText }}</p> + </div> + + <gl-button + v-if="!iosRunnersAvailable" + v-gl-modal-directive="$options.modalId" + category="primary" + variant="confirm" + @click="showModal" + > + {{ $options.i18n.runnerSetupButton }} + </gl-button> + <runner-instructions-modal + v-if="isModalShown" + :modal-id="$options.modalId" + :registration-token="registrationToken" + default-platform-name="osx" + @shown="togglePopover" + @hide="hideModal" + /> + <gl-popover + v-if="isPopoverShown" + :show="true" + :show-close-button="true" + :target="popoverTarget" + triggers="manual" + placement="left" + fallback-placement="clockwise" + > + <template #title> + <gl-sprintf :message="$options.i18n.runnerSetupPopoverTitle"> + <template #emoji="{ content }"> + <gl-emoji class="gl-ml-2" :data-name="content" /> + </template> + </gl-sprintf> + </template> + <div class="gl-mb-5"> + {{ $options.i18n.runnerSetupPopoverBodyLine1 }} + </div> + <gl-sprintf :message="$options.i18n.runnerSetupPopoverBodyLine2"> + <template #link="{ content }"> + <gl-link :href="$options.runnerDocsLink" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-popover> + </div> + </gl-card> + </div> + <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> + <gl-card body-class="gl-display-flex gl-flex-grow-1"> + <div + class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" + > + <div> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="tools" /></div> + <span class="gl-text-gray-800 gl-font-weight-bold"> + {{ $options.i18n.configurePipelineTitle }} + </span> + <p class="gl-font-sm gl-mt-3">{{ $options.i18n.configurePipelineBody }}</p> + </div> + + <gl-button + :disabled="!isRunnerSetupFinished" + category="primary" + variant="confirm" + data-testid="configure-pipeline-link" + :href="configurePipelineLink" + > + {{ $options.i18n.configurePipelineButton }} + </gl-button> + </div> + </gl-card> + </div> + </div> + <h3 class="gl-font-lg gl-text-gray-900 gl-mt-5">{{ $options.i18n.noWalkthroughTitle }}</h3> + <p>{{ $options.i18n.noWalkthroughExplanation }}</p> + <ci-templates + :filter-templates="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + $options.iOSTemplateName, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :disabled="!isRunnerSetupFinished" + /> + <p> + <gl-sprintf :message="$options.i18n.notBuildingForIos"> + <template #link="{ content }"> + <gl-link :href="$options.whatElseLink">{{ content }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue new file mode 100644 index 00000000000..6e7d6908cd9 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue @@ -0,0 +1,53 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; +import PipelinesCiTemplates from './pipelines_ci_templates.vue'; +import IosTemplates from './ios_templates.vue'; + +export default { + i18n: { + noCiDescription: s__('Pipelines|This project is not currently set up to run pipelines.'), + }, + name: 'PipelinesEmptyState', + components: { + GlEmptyState, + GitlabExperiment, + PipelinesCiTemplates, + IosTemplates, + }, + props: { + emptyStateSvgPath: { + type: String, + required: true, + }, + canSetCi: { + type: Boolean, + required: true, + }, + registrationToken: { + type: String, + required: false, + default: null, + }, + }, +}; +</script> +<template> + <div> + <gitlab-experiment v-if="canSetCi" name="ios_specific_templates"> + <template #control> + <pipelines-ci-templates /> + </template> + <template #candidate> + <ios-templates :registration-token="registrationToken" /> + </template> + </gitlab-experiment> + <gl-empty-state + v-else + title="" + :svg-path="emptyStateSvgPath" + :description="$options.i18n.noCiDescription" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue new file mode 100644 index 00000000000..a6297213402 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/pipelines_ci_templates.vue @@ -0,0 +1,79 @@ +<script> +import { GlButton, GlCard, GlSprintf } from '@gitlab/ui'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { STARTER_TEMPLATE_NAME, I18N } from '~/ci/pipeline_editor/constants'; +import Tracking from '~/tracking'; +import CiTemplates from './ci_templates.vue'; + +export default { + components: { + GlButton, + GlCard, + GlSprintf, + CiTemplates, + }, + mixins: [Tracking.mixin()], + STARTER_TEMPLATE_NAME, + I18N, + inject: ['pipelineEditorPath'], + data() { + return { + gettingStartedTemplateUrl: mergeUrlParams( + { template: STARTER_TEMPLATE_NAME }, + this.pipelineEditorPath, + ), + tracker: null, + }; + }, + methods: { + trackEvent(template) { + this.track('template_clicked', { + label: template, + }); + }, + }, +}; +</script> +<template> + <div> + <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.I18N.title }}</h2> + + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.learnBasics.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6"> + <gl-sprintf :message="$options.I18N.learnBasics.subtitle"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + </p> + + <div class="gl-lg-w-25p gl-lg-pr-5 gl-mb-8"> + <gl-card> + <div class="gl-flex-direction-row"> + <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="wave" /></div> + <div class="gl-mb-3"> + <strong class="gl-text-gray-800 gl-mb-2"> + {{ $options.I18N.learnBasics.gettingStarted.title }} + </strong> + </div> + <p class="gl-font-sm">{{ $options.I18N.learnBasics.gettingStarted.description }}</p> + </div> + + <gl-button + category="primary" + variant="confirm" + :href="gettingStartedTemplateUrl" + data-testid="test-template-link" + @click="trackEvent($options.STARTER_TEMPLATE_NAME)" + > + {{ $options.I18N.learnBasics.gettingStarted.cta }} + </gl-button> + </gl-card> + </div> + + <h2 class="gl-font-lg gl-text-gray-900">{{ $options.I18N.templates.title }}</h2> + <p class="gl-text-gray-800 gl-mb-6">{{ $options.I18N.templates.subtitle }}</p> + + <ci-templates /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue new file mode 100644 index 00000000000..82f1d57912a --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_job_details.vue @@ -0,0 +1,165 @@ +<script> +import { GlButton, GlIcon, GlLink, GlTooltip } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { BRIDGE_KIND } from '~/ci/pipeline_details/graph/constants'; +import RetryMrFailedJobMutation from '~/ci/merge_requests/graphql/mutations/retry_mr_failed_job.mutation.graphql'; + +export default { + components: { + CiIcon, + GlButton, + GlIcon, + GlLink, + GlTooltip, + }, + directives: { + SafeHtml, + }, + props: { + job: { + type: Object, + required: true, + }, + }, + data() { + return { + isHovered: false, + isJobLogVisible: false, + isLoadingAction: false, + }; + }, + computed: { + canReadBuild() { + return this.job.userPermissions.readBuild; + }, + canRetryJob() { + return this.job.retryable && this.job.userPermissions.updateBuild && !this.isBridgeJob; + }, + isBridgeJob() { + return this.job.kind === BRIDGE_KIND; + }, + jobChevronName() { + return this.isJobLogVisible ? 'chevron-down' : 'chevron-right'; + }, + jobTrace() { + if (this.canReadBuild) { + return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + } + + return this.$options.i18n.cannotReadBuild; + }, + parsedJobId() { + return getIdFromGraphQLId(this.job.id); + }, + tooltipErrorText() { + return this.isBridgeJob + ? this.$options.i18n.cannotRetryTrigger + : this.$options.i18n.cannotRetry; + }, + tooltipText() { + return sprintf(this.$options.i18n.jobActionTooltipText, { jobName: this.job.name }); + }, + }, + methods: { + setActiveRow() { + this.isHovered = true; + }, + resetActiveRow() { + this.isHovered = false; + }, + async retryJob() { + try { + this.isLoadingAction = true; + + const { + data: { + jobRetry: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: RetryMrFailedJobMutation, + variables: { id: this.job.id }, + }); + + if (errors.length > 0) { + throw new Error(errors[0]); + } + + this.$emit('job-retried', this.job.name); + } catch (error) { + createAlert({ message: error?.message || this.$options.i18n.retryError }); + } finally { + this.isLoadingAction = false; + } + }, + toggleJobLog(event) { + // Do not toggle the log visibility when clicking on a link + if (event.target.tagName === 'A') { + return; + } + this.isJobLogVisible = !this.isJobLogVisible; + }, + }, + i18n: { + cannotReadBuild: s__("Job|You do not have permission to read this job's log."), + cannotRetry: s__('Job|You do not have permission to run this job again.'), + cannotRetryTrigger: s__('Job|You cannot rerun trigger jobs from this list.'), + jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'), + noTraceText: s__('Job|No job log'), + retry: __('Retry'), + retryError: __('There was an error while retrying this job'), + }, +}; +</script> +<template> + <div class="container-fluid gl-grid-tpl-rows-auto"> + <div + class="row gl-my-3 gl-cursor-pointer gl-display-flex gl-align-items-center" + :aria-pressed="isJobLogVisible" + role="button" + tabindex="0" + data-testid="widget-row" + @click="toggleJobLog" + @keyup.enter="toggleJobLog" + @keyup.space="toggleJobLog" + @mouseover="setActiveRow" + @mouseout="resetActiveRow" + > + <div class="col-6 gl-text-gray-900 gl-font-weight-bold gl-text-left"> + <gl-icon :name="jobChevronName" /> + <ci-icon :status="job.detailedStatus" /> + {{ job.name }} + </div> + <div class="col-2 gl-text-left">{{ job.stage.name }}</div> + <div class="col-2 gl-text-left"> + <gl-link :href="job.detailedStatus.detailsPath">#{{ parsedJobId }}</gl-link> + </div> + <gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top"> + {{ tooltipErrorText }} + </gl-tooltip> + <div class="col-2 gl-text-right"> + <span ref="retryBtn"> + <gl-button + :disabled="!canRetryJob" + icon="retry" + category="tertiary" + :loading="isLoadingAction" + :title="$options.i18n.retry" + :aria-label="$options.i18n.retry" + @click.stop="retryJob" + /> + </span> + </div> + </div> + <div v-if="isJobLogVisible" class="row"> + <pre + v-safe-html="jobTrace" + class="gl-bg-gray-900 gl-text-white gl-w-full" + data-testid="job-log" + ></pre> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue new file mode 100644 index 00000000000..138269bdb8a --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/failed_jobs_list.vue @@ -0,0 +1,180 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; +import { getQueryHeaders } from '~/ci/pipeline_details/graph/utils'; +import { graphqlEtagPipelinePath } from '~/ci/pipeline_details/utils'; +import getPipelineFailedJobs from '~/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { sortJobsByStatus } from './utils'; +import FailedJobDetails from './failed_job_details.vue'; + +const POLL_INTERVAL = 10000; + +const JOB_ID_HEADER = __('ID'); +const JOB_NAME_HEADER = __('Name'); +const STAGE_HEADER = __('Stage'); + +export default { + components: { + GlLoadingIcon, + FailedJobDetails, + }, + inject: ['graphqlPath'], + props: { + failedJobsCount: { + required: true, + type: Number, + }, + isPipelineActive: { + required: true, + type: Boolean, + }, + pipelineIid: { + type: Number, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + }, + data() { + return { + failedJobs: [], + isActive: false, + isLoadingMore: false, + }; + }, + apollo: { + failedJobs: { + context() { + return getQueryHeaders(this.graphqlResourceEtag); + }, + query: getPipelineFailedJobs, + pollInterval: POLL_INTERVAL, + variables() { + return { + fullPath: this.projectPath, + pipelineIid: this.pipelineIid, + }; + }, + update(data) { + const jobs = data?.project?.pipeline?.jobs?.nodes || []; + return sortJobsByStatus(jobs); + }, + result({ data }) { + const pipeline = data?.project?.pipeline; + + if (pipeline?.jobs?.count) { + this.$emit('failed-jobs-count', pipeline.jobs.count); + this.isActive = pipeline.active; + } + }, + error(e) { + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + }, + }, + }, + computed: { + graphqlResourceEtag() { + return graphqlEtagPipelinePath(this.graphqlPath, this.pipelineIid); + }, + hasFailedJobs() { + return this.failedJobs.length > 0; + }, + isInitialLoading() { + return this.isLoading && !this.isLoadingMore; + }, + isLoading() { + return this.$apollo.queries.failedJobs.loading; + }, + }, + watch: { + isPipelineActive(flag) { + // Turn polling on and off based on REST actions + // By refetching jobs, we will get the graphql `active` + // field to update properly and cascade the polling changes + this.refetchJobs(); + this.handlePolling(flag); + }, + isActive(flag) { + this.handlePolling(flag); + }, + failedJobsCount(count) { + // If the REST data is updated first, we force a refetch + // to keep them in sync + if (this.failedJobs.length !== count) { + this.$apollo.queries.failedJobs.refetch(); + } + }, + }, + mounted() { + if (!this.isActive && !this.isPipelineActive) { + this.handlePolling(false); + } + }, + methods: { + handlePolling(isActive) { + // If the pipeline status has changed and the widget is not expanded, + // We start polling. + if (isActive) { + this.$apollo.queries.failedJobs.startPolling(POLL_INTERVAL); + } else { + this.$apollo.queries.failedJobs.stopPolling(); + } + }, + async retryJob(jobName) { + await this.refetchJobs(); + + this.$toast.show(sprintf(this.$options.i18n.retriedJobsSuccess, { jobName })); + }, + async refetchJobs() { + this.isLoadingMore = true; + + try { + await this.$apollo.queries.failedJobs.refetch(); + } catch { + createAlert(this.$options.i18n.fetchError); + } finally { + this.isLoadingMore = false; + } + }, + }, + columns: [ + { text: JOB_NAME_HEADER, class: 'col-6' }, + { text: STAGE_HEADER, class: 'col-2' }, + { text: JOB_ID_HEADER, class: 'col-2' }, + ], + i18n: { + fetchError: __('There was a problem fetching failed jobs'), + noFailedJobs: s__('Pipeline|No failed jobs in this pipeline 🎉'), + retriedJobsSuccess: __('%{jobName} job is being retried'), + }, +}; +</script> + +<template> + <div> + <gl-loading-icon v-if="isInitialLoading" class="gl-p-4" /> + <div v-else-if="!hasFailedJobs" class="gl-p-4">{{ $options.i18n.noFailedJobs }}</div> + <div v-else class="container-fluid gl-grid-tpl-rows-auto"> + <div class="row gl-my-4 gl-text-gray-900"> + <div + v-for="col in $options.columns" + :key="col.text" + class="gl-font-weight-bold gl-text-left" + :class="col.class" + data-testid="header" + > + {{ col.text }} + </div> + </div> + </div> + <failed-job-details + v-for="job in failedJobs" + :key="job.id" + :job="job" + @job-retried="retryJob" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue new file mode 100644 index 00000000000..c01037e9791 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/pipeline_failed_jobs_widget.vue @@ -0,0 +1,121 @@ +<script> +import { GlButton, GlCard, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import FailedJobsList from './failed_jobs_list.vue'; + +export default { + components: { + GlButton, + GlCard, + GlIcon, + GlLink, + GlPopover, + GlSprintf, + FailedJobsList, + }, + inject: ['fullPath'], + props: { + failedJobsCount: { + required: true, + type: Number, + }, + isPipelineActive: { + required: true, + type: Boolean, + }, + pipelineIid: { + required: true, + type: Number, + }, + pipelinePath: { + required: true, + type: String, + }, + projectPath: { + required: true, + type: String, + }, + }, + data() { + return { + currentFailedJobsCount: this.failedJobsCount, + isActive: false, + isExpanded: false, + }; + }, + computed: { + bodyClasses() { + return this.isExpanded ? '' : 'gl-display-none'; + }, + failedJobsCountText() { + return sprintf(this.$options.i18n.failedJobsLabel, { count: this.currentFailedJobsCount }); + }, + iconName() { + return this.isExpanded ? 'chevron-down' : 'chevron-right'; + }, + popoverId() { + return `popover-${this.pipelineIid}`; + }, + }, + watch: { + failedJobsCount(val) { + this.currentFailedJobsCount = val; + }, + }, + methods: { + setFailedJobsCount(count) { + this.currentFailedJobsCount = count; + }, + toggleWidget() { + this.isExpanded = !this.isExpanded; + }, + }, + i18n: { + additionalInfoPopover: s__( + 'Pipelines|You will see a maximum of 100 jobs in this list. To view all failed jobs, %{linkStart}go to the details page%{linkEnd} of this pipeline.', + ), + additionalInfoTitle: __('Limitation on this view'), + failedJobsLabel: __('Failed jobs (%{count})'), + }, +}; +</script> +<template> + <gl-card + class="gl-new-card" + :class="{ 'gl-border-white gl-hover-border-gray-100': !isExpanded }" + header-class="gl-new-card-header gl-px-3 gl-py-3" + body-class="gl-new-card-body" + data-testid="failed-jobs-card" + :aria-expanded="isExpanded.toString()" + > + <template #header> + <gl-button + variant="link" + class="gl-text-gray-500! gl-font-weight-semibold" + @click="toggleWidget" + > + <gl-icon :name="iconName" /> + {{ failedJobsCountText }} + <gl-icon :id="popoverId" name="information-o" class="gl-ml-2" /> + <gl-popover :target="popoverId" placement="top"> + <template #title> {{ $options.i18n.additionalInfoTitle }} </template> + <slot> + <gl-sprintf :message="$options.i18n.additionalInfoPopover"> + <template #link="{ content }"> + <gl-link class="gl-font-sm" :href="pipelinePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </slot> + </gl-popover> + </gl-button> + </template> + <failed-jobs-list + v-if="isExpanded" + :failed-jobs-count="failedJobsCount" + :is-pipeline-active="isPipelineActive" + :pipeline-iid="pipelineIid" + :project-path="projectPath" + @failed-jobs-count="setFailedJobsCount" + /> + </gl-card> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js new file mode 100644 index 00000000000..3f395fff7e0 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/failure_widget/utils.js @@ -0,0 +1,15 @@ +export const isFailedJob = (job = {}) => { + return job?.detailedStatus?.group === 'failed' || false; +}; + +export const sortJobsByStatus = (jobs = []) => { + const newJobs = [...jobs]; + + return newJobs.sort((a) => { + if (isFailedJob(a)) { + return -1; + } + + return 1; + }); +}; diff --git a/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue new file mode 100644 index 00000000000..235126fea0c --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/nav_controls.vue @@ -0,0 +1,69 @@ +<script> +import { GlButton } from '@gitlab/ui'; + +export default { + name: 'PipelineNavControls', + components: { + GlButton, + }, + props: { + newPipelinePath: { + type: String, + required: false, + default: null, + }, + + resetCachePath: { + type: String, + required: false, + default: null, + }, + + ciLintPath: { + type: String, + required: false, + default: null, + }, + + isResetCacheButtonLoading: { + type: Boolean, + required: false, + default: false, + }, + }, + methods: { + onClickResetCache() { + this.$emit('resetRunnersCache', this.resetCachePath); + }, + }, +}; +</script> +<template> + <div class="nav-controls"> + <gl-button + v-if="resetCachePath" + :loading="isResetCacheButtonLoading" + class="js-clear-cache" + data-testid="clear-cache-button" + @click="onClickResetCache" + > + {{ s__('Pipelines|Clear runner caches') }} + </gl-button> + + <gl-button v-if="ciLintPath" :href="ciLintPath" class="js-ci-lint" data-testid="ci-lint-button"> + {{ s__('Pipelines|CI lint') }} + </gl-button> + + <gl-button + v-if="newPipelinePath" + :href="newPipelinePath" + variant="confirm" + category="primary" + class="js-run-pipeline" + data-testid="run-pipeline-button" + data-qa-selector="run_pipeline_button" + > + {{ s__('Pipeline|Run pipeline') }} + </gl-button> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue new file mode 100644 index 00000000000..082ede60244 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue @@ -0,0 +1,170 @@ +<script> +import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { SCHEDULE_ORIGIN } from '~/ci/pipeline_details/constants'; + +export default { + components: { + GlBadge, + GlLink, + GlPopover, + GlSprintf, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: { + targetProjectFullPath: { + default: '', + }, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + pipelineScheduleUrl: { + type: String, + required: true, + }, + }, + computed: { + isScheduled() { + return this.pipeline.source === SCHEDULE_ORIGIN; + }, + isInFork() { + return Boolean( + this.targetProjectFullPath && + this.pipeline?.project?.full_path !== `/${this.targetProjectFullPath}`, + ); + }, + autoDevopsTagId() { + return `pipeline-url-autodevops-${this.pipeline.id}`; + }, + autoDevopsHelpPath() { + return helpPagePath('topics/autodevops/index.md'); + }, + }, +}; +</script> +<template> + <div class="label-container gl-mt-1"> + <gl-badge + v-if="isScheduled" + v-gl-tooltip + :href="pipelineScheduleUrl" + target="__blank" + :title="__('This pipeline was created by a schedule.')" + variant="info" + size="sm" + data-testid="pipeline-url-scheduled" + >{{ __('Scheduled') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.latest" + v-gl-tooltip + :title="__('Latest pipeline for the most recent commit on this branch')" + variant="success" + size="sm" + data-testid="pipeline-url-latest" + >{{ __('latest') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.merge_train_pipeline" + v-gl-tooltip + :title=" + s__( + 'Pipeline|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + ) + " + variant="info" + size="sm" + data-testid="pipeline-url-train" + >{{ s__('Pipeline|merge train') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.yaml_errors" + v-gl-tooltip + :title="pipeline.yaml_errors" + variant="danger" + size="sm" + data-testid="pipeline-url-yaml" + >{{ __('yaml invalid') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.failure_reason" + v-gl-tooltip + :title="pipeline.failure_reason" + variant="danger" + size="sm" + data-testid="pipeline-url-failure" + >{{ __('error') }}</gl-badge + > + <template v-if="pipeline.flags.auto_devops"> + <gl-link + :id="autoDevopsTagId" + tabindex="0" + data-testid="pipeline-url-autodevops" + role="button" + > + <gl-badge variant="info" size="sm"> + {{ __('Auto DevOps') }} + </gl-badge> + </gl-link> + <gl-popover :target="autoDevopsTagId" triggers="focus" placement="top"> + <template #title> + <div class="gl-font-weight-normal gl-line-height-normal"> + <gl-sprintf + :message=" + __( + 'This pipeline makes use of a predefined CI/CD configuration enabled by %{strongStart}Auto DevOps.%{strongEnd}', + ) + " + > + <template #strong="{ content }"> + <b>{{ content }}</b> + </template> + </gl-sprintf> + </div> + </template> + <gl-link + :href="autoDevopsHelpPath" + data-testid="pipeline-url-autodevops-link" + target="_blank" + > + {{ __('Learn more about Auto DevOps') }} + </gl-link> + </gl-popover> + </template> + + <gl-badge + v-if="pipeline.flags.stuck" + variant="warning" + size="sm" + data-testid="pipeline-url-stuck" + >{{ __('stuck') }}</gl-badge + > + <gl-badge + v-if="pipeline.flags.detached_merge_request_pipeline" + v-gl-tooltip + :title=" + s__( + `Pipeline|This pipeline ran on the contents of this merge request's source branch, not the target branch.`, + ) + " + variant="info" + size="sm" + data-testid="pipeline-url-detached" + >{{ s__('Pipeline|merge request') }}</gl-badge + > + <gl-badge + v-if="isInFork" + v-gl-tooltip + :title="__('Pipeline ran in fork of project')" + variant="info" + size="sm" + data-testid="pipeline-url-fork" + >{{ __('fork') }}</gl-badge + > + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue new file mode 100644 index 00000000000..78acead95f4 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_multi_actions.vue @@ -0,0 +1,189 @@ +<script> +import { + GlAlert, + GlDisclosureDropdown, + GlSearchBoxByType, + GlLoadingIcon, + GlTooltipDirective, +} from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import axios from '~/lib/utils/axios_utils'; +import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { TRACKING_CATEGORIES } from '../../constants'; + +export const i18n = { + searchPlaceholder: __('Search artifacts'), + downloadArtifacts: __('Download artifacts'), + artifactsFetchErrorMessage: s__('Pipelines|Could not load artifacts.'), + artifactsFetchWarningMessage: s__( + 'Pipelines|Failed to update. Please reload page to update the list of artifacts.', + ), + emptyArtifactsMessage: __('No artifacts found'), +}; + +export default { + i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlAlert, + GlDisclosureDropdown, + GlSearchBoxByType, + GlLoadingIcon, + }, + mixins: [Tracking.mixin()], + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, + }, + props: { + pipelineId: { + type: Number, + required: true, + }, + }, + data() { + return { + artifacts: [], + hasError: false, + isLoading: false, + searchQuery: '', + isNewPipeline: false, + }; + }, + computed: { + hasArtifacts() { + return this.artifacts.length > 0; + }, + filteredArtifacts() { + return this.searchQuery.length > 0 + ? fuzzaldrinPlus.filter(this.artifacts, this.searchQuery, { key: 'name' }) + : this.artifacts; + }, + items() { + return this.filteredArtifacts.map(({ name, path }) => ({ + text: name, + href: path, + extraAttrs: { + download: '', + rel: 'nofollow', + 'data-testid': 'artifact-item', + }, + })); + }, + }, + watch: { + pipelineId() { + this.isNewPipeline = true; + }, + }, + methods: { + fetchArtifacts() { + // refactor tracking based on action once this dropdown supports + // actions other than artifacts + this.track('click_artifacts_dropdown', { label: TRACKING_CATEGORIES.table }); + + // Preserve the last good list and present it if a request fails + const oldArtifacts = [...this.artifacts]; + this.artifacts = []; + + this.hasError = false; + this.isLoading = true; + + // Replace the placeholder with the ID of the pipeline we are viewing + const endpoint = this.artifactsEndpoint.replace( + this.artifactsEndpointPlaceholder, + this.pipelineId, + ); + return axios + .get(endpoint) + .then(({ data }) => { + this.artifacts = data.artifacts; + this.isNewPipeline = false; + }) + .catch(() => { + this.hasError = true; + if (!this.isNewPipeline) { + this.artifacts = oldArtifacts; + } + }) + .finally(() => { + this.isLoading = false; + }); + }, + onDisclosureDropdownShown() { + this.fetchArtifacts(); + }, + onDisclosureDropdownHidden() { + this.searchQuery = ''; + }, + }, +}; +</script> +<template> + <gl-disclosure-dropdown + v-gl-tooltip + class="gl-text-left" + :title="$options.i18n.downloadArtifacts" + :toggle-text="$options.i18n.downloadArtifacts" + :aria-label="$options.i18n.downloadArtifacts" + :items="items" + icon="download" + placement="right" + text-sr-only + data-testid="pipeline-multi-actions-dropdown" + @shown="onDisclosureDropdownShown" + @hidden="onDisclosureDropdownHidden" + > + <template #header> + <div + aria-hidden="true" + class="gl-display-flex gl-align-items-center gl-p-4! gl-min-h-8 gl-font-sm gl-font-weight-bold gl-text-gray-900 gl-border-b-1 gl-border-b-solid gl-border-b-gray-200" + > + {{ $options.i18n.downloadArtifacts }} + </div> + <div v-if="hasArtifacts" class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> + <gl-search-box-by-type + ref="searchInput" + v-model.trim="searchQuery" + :placeholder="$options.i18n.searchPlaceholder" + borderless + autofocus + /> + </div> + <gl-alert + v-if="hasError && !hasArtifacts" + variant="danger" + :dismissible="false" + data-testid="artifacts-fetch-error" + > + {{ $options.i18n.artifactsFetchErrorMessage }} + </gl-alert> + </template> + + <gl-loading-icon v-if="isLoading" class="gl-m-3" size="sm" /> + <p + v-else-if="filteredArtifacts.length === 0" + class="gl-px-4 gl-py-3 gl-m-0 gl-text-gray-600" + data-testid="artifacts-empty-message" + > + {{ $options.i18n.emptyArtifactsMessage }} + </p> + + <template #footer> + <p + v-if="hasError && hasArtifacts" + class="gl-font-sm gl-text-secondary gl-py-4 gl-px-5 gl-mb-0 gl-border-t" + data-testid="artifacts-fetch-warning" + > + {{ $options.i18n.artifactsFetchWarningMessage }} + </p> + </template> + </gl-disclosure-dropdown> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue new file mode 100644 index 00000000000..b05bdae65c4 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_operations.vue @@ -0,0 +1,113 @@ +<script> +import { GlButton, GlTooltipDirective, GlModalDirective } from '@gitlab/ui'; +import Tracking from '~/tracking'; +import { BUTTON_TOOLTIP_RETRY, BUTTON_TOOLTIP_CANCEL, TRACKING_CATEGORIES } from '~/ci/constants'; +import eventHub from '../../event_hub'; +import PipelineMultiActions from './pipeline_multi_actions.vue'; +import PipelinesManualActions from './pipelines_manual_actions.vue'; + +export default { + BUTTON_TOOLTIP_RETRY, + BUTTON_TOOLTIP_CANCEL, + directives: { + GlTooltip: GlTooltipDirective, + GlModalDirective, + }, + components: { + GlButton, + PipelineMultiActions, + PipelinesManualActions, + }, + mixins: [Tracking.mixin()], + props: { + pipeline: { + type: Object, + required: true, + }, + cancelingPipeline: { + type: Number, + required: false, + default: null, + }, + }, + data() { + return { + isRetrying: false, + }; + }, + computed: { + hasActions() { + return ( + this.pipeline?.details?.has_manual_actions || this.pipeline?.details?.has_scheduled_actions + ); + }, + isCancelling() { + return this.cancelingPipeline === this.pipeline.id; + }, + }, + watch: { + pipeline() { + this.isRetrying = false; + }, + }, + methods: { + handleCancelClick() { + this.trackClick('click_cancel_button'); + eventHub.$emit('openConfirmationModal', { + pipeline: this.pipeline, + endpoint: this.pipeline.cancel_path, + }); + }, + handleRetryClick() { + this.isRetrying = true; + this.trackClick('click_retry_button'); + eventHub.$emit('retryPipeline', this.pipeline.retry_path); + }, + trackClick(action) { + this.track(action, { label: TRACKING_CATEGORIES.table }); + }, + }, +}; +</script> + +<template> + <div class="gl-text-right"> + <div class="btn-group"> + <pipelines-manual-actions v-if="hasActions" :iid="pipeline.iid" /> + + <gl-button + v-if="pipeline.flags.retryable" + v-gl-tooltip.hover + :aria-label="$options.BUTTON_TOOLTIP_RETRY" + :title="$options.BUTTON_TOOLTIP_RETRY" + :disabled="isRetrying" + :loading="isRetrying" + class="js-pipelines-retry-button" + data-qa-selector="pipeline_retry_button" + data-testid="pipelines-retry-button" + icon="retry" + variant="default" + category="secondary" + @click="handleRetryClick" + /> + + <gl-button + v-if="pipeline.flags.cancelable" + v-gl-tooltip.hover + v-gl-modal-directive="'confirmation-modal'" + :aria-label="$options.BUTTON_TOOLTIP_CANCEL" + :title="$options.BUTTON_TOOLTIP_CANCEL" + :loading="isCancelling" + :disabled="isCancelling" + icon="cancel" + variant="danger" + category="primary" + class="js-pipelines-cancel-button gl-ml-1" + data-testid="pipelines-cancel-button" + @click="handleCancelClick" + /> + + <pipeline-multi-actions :pipeline-id="pipeline.id" /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue new file mode 100644 index 00000000000..9f38be668f2 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_stop_modal.vue @@ -0,0 +1,104 @@ +<script> +import { GlLink, GlModal, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { __, s__, sprintf } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; + +/** + * Pipeline Stop Modal. + * + * Renders the modal used to confirm stopping a pipeline. + */ +export default { + components: { + GlModal, + GlLink, + GlSprintf, + CiIcon, + }, + props: { + pipeline: { + type: Object, + required: true, + deep: true, + }, + }, + computed: { + modalTitle() { + return sprintf( + s__('Pipeline|Stop pipeline #%{pipelineId}?'), + { + pipelineId: `${this.pipeline.id}`, + }, + false, + ); + }, + modalText() { + return s__(`Pipeline|You’re about to stop pipeline #%{pipelineId}.`); + }, + hasRef() { + return !isEmpty(this.pipeline.ref); + }, + primaryProps() { + return { + text: s__('Pipeline|Stop pipeline'), + attributes: { variant: 'danger' }, + }; + }, + cancelProps() { + return { + text: __('Cancel'), + }; + }, + }, + methods: { + emitSubmit(event) { + this.$emit('submit', event); + }, + }, +}; +</script> +<template> + <gl-modal + modal-id="confirmation-modal" + :title="modalTitle" + :action-primary="primaryProps" + :action-cancel="cancelProps" + @primary="emitSubmit($event)" + > + <p> + <gl-sprintf :message="modalText"> + <template #pipelineId> + <strong>{{ pipeline.id }}</strong> + </template> + </gl-sprintf> + </p> + + <p v-if="pipeline"> + <ci-icon + v-if="pipeline.details" + :status="pipeline.details.status" + class="vertical-align-middle" + /> + + <span class="font-weight-bold">{{ __('Pipeline') }}</span> + + <a :href="pipeline.path" class="js-pipeline-path link-commit">#{{ pipeline.id }}</a> + <template v-if="hasRef"> + {{ __('from') }} + <a :href="pipeline.ref.path" class="link-commit ref-name">{{ pipeline.ref.name }}</a> + </template> + </p> + + <template v-if="pipeline.commit"> + <p> + <span class="font-weight-bold">{{ __('Commit') }}</span> + + <gl-link :href="pipeline.commit.commit_path" class="js-commit-sha commit-sha link-commit"> + {{ pipeline.commit.short_id }} + </gl-link> + </p> + <p>{{ pipeline.commit.title }}</p> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue new file mode 100644 index 00000000000..2a73795db0a --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue @@ -0,0 +1,37 @@ +<script> +import { GlAvatarLink, GlAvatar, GlTooltipDirective } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + components: { + GlAvatarLink, + GlAvatar, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + pipeline: { + type: Object, + required: true, + }, + }, + computed: { + user() { + return this.pipeline.user; + }, + }, +}; +</script> +<template> + <div class="pipeline-triggerer" data-testid="pipeline-triggerer"> + <gl-avatar-link v-if="user" v-gl-tooltip :href="user.path" :title="user.name" class="gl-ml-3"> + <gl-avatar :size="32" :src="user.avatar_url" /> + </gl-avatar-link> + + <span v-else class="gl-ml-3"> + {{ s__('Pipelines|API') }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue new file mode 100644 index 00000000000..edaeb481d7b --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_url.vue @@ -0,0 +1,242 @@ +<script> +import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import Tracking from '~/tracking'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; +import { ICONS, TRACKING_CATEGORIES } from '~/ci/constants'; +import PipelineLabels from './pipeline_labels.vue'; + +export default { + components: { + GlIcon, + GlLink, + PipelineLabels, + TooltipOnTruncate, + UserAvatarLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin()], + props: { + pipeline: { + type: Object, + required: true, + }, + pipelineScheduleUrl: { + type: String, + required: true, + }, + pipelineKey: { + type: String, + required: true, + }, + refClass: { + type: String, + required: false, + default: '', + }, + }, + computed: { + mergeRequestRef() { + return this.pipeline?.merge_request; + }, + commitRef() { + return this.pipeline?.ref; + }, + commitTag() { + return this.commitRef?.tag; + }, + commitUrl() { + return this.pipeline?.commit?.commit_path; + }, + commitShortSha() { + return this.pipeline?.commit?.short_id; + }, + refUrl() { + return this.commitRef?.ref_url || this.commitRef?.path; + }, + tooltipTitle() { + return this.mergeRequestRef?.title || this.commitRef?.name; + }, + commitAuthor() { + let commitAuthorInformation; + const pipelineCommit = this.pipeline?.commit; + const pipelineCommitAuthor = pipelineCommit?.author; + + if (!pipelineCommit) { + return null; + } + + // 1. person who is an author of a commit might be a GitLab user + if (pipelineCommitAuthor) { + // 2. if person who is an author of a commit is a GitLab user + // they can have a GitLab avatar + if (pipelineCommitAuthor?.avatar_url) { + commitAuthorInformation = pipelineCommitAuthor; + + // 3. If GitLab user does not have avatar, they might have a Gravatar + } else if (pipelineCommit.author_gravatar_url) { + commitAuthorInformation = { + ...pipelineCommitAuthor, + avatar_url: pipelineCommit.author_gravatar_url, + }; + } + // 4. If committer is not a GitLab User, they can have a Gravatar + } else { + commitAuthorInformation = { + avatar_url: pipelineCommit.author_gravatar_url, + path: `mailto:${pipelineCommit.author_email}`, + username: pipelineCommit.author_name, + }; + } + + return commitAuthorInformation; + }, + commitIcon() { + let name = ''; + + if (this.commitTag) { + name = ICONS.TAG; + } else if (this.mergeRequestRef) { + name = ICONS.MR; + } else { + name = ICONS.BRANCH; + } + + return name; + }, + commitIconTooltipTitle() { + switch (this.commitIcon) { + case ICONS.TAG: + return __('Tag'); + case ICONS.MR: + return __('Merge Request'); + default: + return __('Branch'); + } + }, + commitTitle() { + return this.pipeline?.commit?.title; + }, + pipelineName() { + return this.pipeline?.name; + }, + }, + methods: { + trackClick(action) { + this.track(action, { label: TRACKING_CATEGORIES.table }); + }, + }, +}; +</script> +<template> + <div class="pipeline-tags" data-testid="pipeline-url-table-cell"> + <div v-if="pipelineName" class="gl-mb-2" data-testid="pipeline-name-container"> + <span class="gl-display-flex"> + <tooltip-on-truncate + :title="pipelineName" + class="gl-flex-grow-1 gl-text-truncate gl-text-gray-900" + > + <gl-link + :href="pipeline.path" + class="gl-text-blue-600!" + data-testid="pipeline-url-link" + >{{ pipelineName }}</gl-link + > + </tooltip-on-truncate> + </span> + </div> + + <div v-if="!pipelineName" class="commit-title gl-mb-2" data-testid="commit-title-container"> + <span v-if="commitTitle" class="gl-display-flex"> + <tooltip-on-truncate + :title="commitTitle" + class="gl-flex-grow-1 gl-text-truncate gl-p-3 gl-ml-n3 gl-mr-n3 gl-mt-n3 gl-mb-n3" + > + <gl-link + :href="commitUrl" + class="commit-row-message gl-text-blue-600!" + data-testid="commit-title" + @click="trackClick('click_commit_title')" + >{{ commitTitle }}</gl-link + > + </tooltip-on-truncate> + </span> + <span v-else class="gl-text-gray-500">{{ + __("Can't find HEAD commit for this branch") + }}</span> + </div> + <div class="gl-mb-2"> + <gl-link + :href="pipeline.path" + class="gl-mr-1 gl-text-blue-500!" + data-testid="pipeline-url-link" + data-qa-selector="pipeline_url_link" + @click="trackClick('click_pipeline_id')" + >#{{ pipeline[pipelineKey] }}</gl-link + > + <!--Commit row--> + <div class="gl-display-inline-flex gl-rounded-base gl-px-2 gl-bg-gray-50 gl-text-gray-700"> + <tooltip-on-truncate :title="tooltipTitle" truncate-target="child" placement="top"> + <gl-icon + v-gl-tooltip + :name="commitIcon" + :title="commitIconTooltipTitle" + :size="12" + data-testid="commit-icon-type" + /> + <gl-link + v-if="mergeRequestRef" + :href="mergeRequestRef.path" + class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!" + :class="refClass" + data-testid="merge-request-ref" + @click="trackClick('click_mr_ref')" + >{{ mergeRequestRef.iid }}</gl-link + > + <gl-link + v-else + :href="refUrl" + class="gl-font-sm gl-font-monospace gl-text-gray-700! gl-hover-text-gray-900!" + :class="refClass" + data-testid="commit-ref-name" + @click="trackClick('click_commit_name')" + >{{ commitRef.name }}</gl-link + > + </tooltip-on-truncate> + </div> + <div + class="gl-display-inline-block gl-rounded-base gl-font-sm gl-px-2 gl-bg-gray-50 gl-text-black-normal" + > + <gl-icon + v-gl-tooltip + name="commit" + class="commit-icon gl-mr-1" + :title="__('Commit')" + :size="12" + data-testid="commit-icon" + /> + <gl-link + :href="commitUrl" + class="gl-font-sm gl-font-monospace gl-mr-0 gl-text-gray-700!" + data-testid="commit-short-sha" + @click="trackClick('click_commit_sha')" + >{{ commitShortSha }}</gl-link + > + </div> + <user-avatar-link + v-if="commitAuthor" + :link-href="commitAuthor.path" + :img-src="commitAuthor.avatar_url" + :img-size="16" + :img-alt="commitAuthor.name" + :tooltip-text="commitAuthor.name" + class="gl-ml-1" + /> + <!--End of commit row--> + </div> + <pipeline-labels :pipeline-schedule-url="pipelineScheduleUrl" :pipeline="pipeline" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue new file mode 100644 index 00000000000..3021b4a2ef8 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_artifacts.vue @@ -0,0 +1,72 @@ +<script> +import { GlDisclosureDropdown, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; + +export const i18n = { + artifacts: __('Artifacts'), + artifactSectionHeader: __('Download artifacts'), +}; + +export default { + i18n, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlDisclosureDropdown, + }, + inject: { + artifactsEndpoint: { + default: '', + }, + artifactsEndpointPlaceholder: { + default: '', + }, + }, + props: { + pipelineId: { + type: Number, + required: true, + }, + artifacts: { + type: Array, + required: false, + default: () => [], + }, + }, + computed: { + items() { + return [ + { + name: this.$options.i18n.artifactSectionHeader, + items: this.artifacts.map(({ name, path }) => ({ + text: name, + href: path, + extraAttrs: { + download: '', + rel: 'nofollow', + }, + })), + }, + ]; + }, + shouldShowDropdown() { + return this.artifacts?.length; + }, + }, +}; +</script> +<template> + <gl-disclosure-dropdown + v-if="shouldShowDropdown" + v-gl-tooltip + class="gl-text-left" + :title="$options.i18n.artifacts" + :toggle-text="$options.i18n.artifacts" + :aria-label="$options.i18n.artifacts" + icon="download" + placement="right" + text-sr-only + :items="items" + /> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue new file mode 100644 index 00000000000..6aadb6b73c8 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_filtered_search.vue @@ -0,0 +1,130 @@ +<script> +import { GlFilteredSearch } from '@gitlab/ui'; +import { map } from 'lodash'; +import { s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import { TRACKING_CATEGORIES } from '../../constants'; +import PipelineBranchNameToken from '../tokens/pipeline_branch_name_token.vue'; +import PipelineSourceToken from '../tokens/pipeline_source_token.vue'; +import PipelineStatusToken from '../tokens/pipeline_status_token.vue'; +import PipelineTagNameToken from '../tokens/pipeline_tag_name_token.vue'; +import PipelineTriggerAuthorToken from '../tokens/pipeline_trigger_author_token.vue'; + +export default { + userType: 'username', + branchType: 'ref', + tagType: 'tag', + statusType: 'status', + sourceType: 'source', + defaultTokensLength: 1, + components: { + GlFilteredSearch, + }, + mixins: [Tracking.mixin()], + props: { + projectId: { + type: String, + required: true, + }, + defaultBranchName: { + type: String, + required: false, + default: null, + }, + params: { + type: Object, + required: true, + }, + }, + data() { + return { + internalValue: [], + }; + }, + computed: { + selectedTypes() { + return this.value.map((i) => i.type); + }, + tokens() { + return [ + { + type: this.$options.userType, + icon: 'user', + title: s__('Pipeline|Trigger author'), + unique: true, + token: PipelineTriggerAuthorToken, + operators: OPERATORS_IS, + projectId: this.projectId, + }, + { + type: this.$options.branchType, + icon: 'branch', + title: s__('Pipeline|Branch name'), + unique: true, + token: PipelineBranchNameToken, + operators: OPERATORS_IS, + projectId: this.projectId, + defaultBranchName: this.defaultBranchName, + disabled: this.selectedTypes.includes(this.$options.tagType), + }, + { + type: this.$options.tagType, + icon: 'tag', + title: s__('Pipeline|Tag name'), + unique: true, + token: PipelineTagNameToken, + operators: OPERATORS_IS, + projectId: this.projectId, + disabled: this.selectedTypes.includes(this.$options.branchType), + }, + { + type: this.$options.statusType, + icon: 'status', + title: s__('Pipeline|Status'), + unique: true, + token: PipelineStatusToken, + operators: OPERATORS_IS, + }, + { + type: this.$options.sourceType, + icon: 'trigger-source', + title: s__('Pipeline|Source'), + unique: true, + token: PipelineSourceToken, + operators: OPERATORS_IS, + }, + ]; + }, + parsedParams() { + return map(this.params, (val, key) => ({ + type: key, + value: { data: val, operator: '=' }, + })); + }, + value: { + get() { + return this.internalValue.length > 0 ? this.internalValue : this.parsedParams; + }, + set(value) { + this.internalValue = value; + }, + }, + }, + methods: { + onSubmit(filters) { + this.track('click_filtered_search', { label: TRACKING_CATEGORIES.search }); + this.$emit('filterPipelines', filters); + }, + }, +}; +</script> + +<template> + <gl-filtered-search + v-model="value" + :placeholder="__('Filter pipelines')" + :available-tokens="tokens" + @submit="onSubmit" + /> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue new file mode 100644 index 00000000000..4dacd474bde --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_manual_actions.vue @@ -0,0 +1,159 @@ +<script> +import { GlDropdown, GlDropdownItem, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; +import { s__, __, sprintf } from '~/locale'; +import Tracking from '~/tracking'; +import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; +import eventHub from '../../event_hub'; +import { TRACKING_CATEGORIES } from '../../constants'; +import getPipelineActionsQuery from '../graphql/queries/get_pipeline_actions.query.graphql'; + +export default { + name: 'PipelinesManualActions', + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlCountdown, + GlDropdown, + GlDropdownItem, + GlIcon, + GlLoadingIcon, + }, + mixins: [Tracking.mixin()], + inject: ['fullPath', 'manualActionsLimit'], + props: { + iid: { + type: Number, + required: true, + }, + }, + apollo: { + actions: { + query: getPipelineActionsQuery, + variables() { + return { + fullPath: this.fullPath, + iid: this.iid, + limit: this.manualActionsLimit, + }; + }, + skip() { + return !this.hasDropdownBeenShown; + }, + update({ project }) { + return project?.pipeline?.jobs?.nodes || []; + }, + }, + }, + data() { + return { + isLoading: false, + actions: [], + hasDropdownBeenShown: false, + }; + }, + computed: { + isActionsLoading() { + return this.$apollo.queries.actions.loading; + }, + isDropdownLimitReached() { + return this.actions.length === this.manualActionsLimit; + }, + }, + methods: { + async onClickAction(action) { + if (action.scheduledAt) { + const confirmationMessage = sprintf( + s__( + 'DelayedJobs|Are you sure you want to run %{jobName} immediately? Otherwise this job will run automatically after its timer finishes.', + ), + { jobName: action.name }, + ); + + const confirmed = await confirmAction(confirmationMessage); + + if (!confirmed) { + return; + } + } + + this.isLoading = true; + + /** + * Ideally, the component would not make an api call directly. + * However, in order to use the eventhub and know when to + * toggle back the `isLoading` property we'd need an ID + * to track the request with a watcher - since this component + * is rendered at least 20 times in the same page, moving the + * api call directly here is the most performant solution + */ + axios + .post(`${action.playPath}.json`) + .then(() => { + this.isLoading = false; + eventHub.$emit('updateTable'); + }) + .catch(() => { + this.isLoading = false; + createAlert({ message: __('An error occurred while making the request.') }); + }); + }, + fetchActions() { + this.hasDropdownBeenShown = true; + + this.$apollo.queries.actions.refetch(); + + this.trackClick(); + }, + trackClick() { + this.track('click_manual_actions', { label: TRACKING_CATEGORIES.table }); + }, + }, +}; +</script> +<template> + <gl-dropdown + v-gl-tooltip + :title="__('Run manual or delayed jobs')" + :loading="isLoading" + data-testid="pipelines-manual-actions-dropdown" + right + lazy + icon="play" + @shown="fetchActions" + > + <gl-dropdown-item v-if="isActionsLoading"> + <div class="gl-display-flex"> + <gl-loading-icon class="mr-2" /> + <span>{{ __('Loading...') }}</span> + </div> + </gl-dropdown-item> + + <gl-dropdown-item + v-for="action in actions" + v-else + :key="action.id" + :disabled="!action.canPlayJob" + @click="onClickAction(action)" + > + <div class="gl-display-flex gl-justify-content-space-between gl-flex-wrap"> + {{ action.name }} + <span v-if="action.scheduledAt"> + <gl-icon name="clock" /> + <gl-countdown :end-date-string="action.scheduledAt" /> + </span> + </div> + </gl-dropdown-item> + + <template #footer> + <gl-dropdown-item v-if="isDropdownLimitReached"> + <span class="gl-font-sm gl-text-gray-300!" data-testid="limit-reached-msg"> + {{ __('Showing first 50 actions.') }} + </span> + </gl-dropdown-item> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue new file mode 100644 index 00000000000..2da9141df8e --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/pipelines_status_badge.vue @@ -0,0 +1,51 @@ +<script> +import { TRACKING_CATEGORIES } from '~/ci/constants'; +import { CHILD_VIEW } from '~/ci/pipeline_details/constants'; +import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import Tracking from '~/tracking'; +import PipelinesTimeago from './time_ago.vue'; + +export default { + components: { + CiBadgeLink, + PipelinesTimeago, + }, + mixins: [Tracking.mixin()], + props: { + pipeline: { + type: Object, + required: true, + }, + viewType: { + type: String, + required: true, + }, + }, + computed: { + pipelineStatus() { + return this.pipeline?.details?.status ?? {}; + }, + isChildView() { + return this.viewType === CHILD_VIEW; + }, + }, + methods: { + trackClick() { + this.track('click_ci_status_badge', { label: TRACKING_CATEGORIES.table }); + }, + }, +}; +</script> + +<template> + <div> + <ci-badge-link + class="gl-mb-3" + :status="pipelineStatus" + :show-text="!isChildView" + data-qa-selector="pipeline_commit_status" + @ciStatusBadgeClick="trackClick" + /> + <pipelines-timeago :pipeline="pipeline" /> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue new file mode 100644 index 00000000000..70343544638 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/components/time_ago.vue @@ -0,0 +1,61 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { formatTime } from '~/lib/utils/datetime_utility'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { GlIcon }, + mixins: [timeagoMixin], + props: { + pipeline: { + type: Object, + required: true, + }, + fontSize: { + type: String, + required: false, + default: 'gl-font-sm', + validator: (fontSize) => ['gl-font-sm', 'gl-font-md'].includes(fontSize), + }, + }, + computed: { + duration() { + return this.pipeline?.details?.duration; + }, + durationFormatted() { + return formatTime(this.duration * 1000); + }, + finishedTime() { + return this.pipeline?.details?.finished_at || this.pipeline?.finishedAt; + }, + }, +}; +</script> +<template> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-flex-end gl-lg-align-items-flex-start" + :class="fontSize" + > + <p v-if="duration" class="duration gl-display-inline-flex gl-align-items-center"> + <gl-icon name="timer" class="gl-mr-2" :size="12" /> + {{ durationFormatted }} + </p> + + <p v-if="finishedTime" class="finished-at gl-display-inline-flex gl-align-items-center"> + <gl-icon name="calendar" class="gl-mr-2" :size="12" data-testid="calendar-icon" /> + + <time + v-gl-tooltip + :title="tooltipTitle(finishedTime)" + :datetime="finishedTime" + data-placement="top" + data-container="body" + > + {{ timeFormatted(finishedTime) }} + </time> + </p> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/constants.js b/app/assets/javascripts/ci/pipelines_page/constants.js new file mode 100644 index 00000000000..aa6ef8a25ee --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/constants.js @@ -0,0 +1,2 @@ +export const ANY_TRIGGER_AUTHOR = 'Any'; +export const FILTER_PIPELINES_SEARCH_DELAY = 200; diff --git a/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql new file mode 100644 index 00000000000..d1878c01e91 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_actions.query.graphql @@ -0,0 +1,24 @@ +query getPipelineActions($fullPath: ID!, $iid: ID!, $limit: Int) { + project(fullPath: $fullPath) { + id + pipeline(iid: $iid) { + id + jobs( + first: $limit + whenExecuted: ["manual", "delayed"] + retried: false + statuses: [MANUAL, SCHEDULED, SUCCESS, FAILED, SKIPPED, CANCELED] + ) { + nodes { + id + name + canPlayJob + manualJob + scheduledAt + scheduled + playPath + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql new file mode 100644 index 00000000000..6b553866f63 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs.query.graphql @@ -0,0 +1,42 @@ +query getPipelineFailedJobs($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + active + jobs(statuses: [FAILED], retried: false) { + count + nodes { + id + allowFailure + detailedStatus { + id + detailsPath + group + icon + action { + id + path + icon + } + } + kind + name + retried + retryable + stage { + id + name + } + trace { + htmlSummary + } + userPermissions { + readBuild + updateBuild + } + } + } + } + } +} diff --git a/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql new file mode 100644 index 00000000000..b70e95deab6 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/graphql/queries/get_pipeline_failed_jobs_count.query.graphql @@ -0,0 +1,12 @@ +query getPipelineFailedJobsCount($fullPath: ID!, $pipelineIid: ID!) { + project(fullPath: $fullPath) { + id + pipeline(iid: $pipelineIid) { + id + active + jobs(statuses: [FAILED], retried: false, jobKind: BUILD) { + count + } + } + } +} diff --git a/app/assets/javascripts/ci/pipelines_page/pipelines.vue b/app/assets/javascripts/ci/pipelines_page/pipelines.vue new file mode 100644 index 00000000000..87ee5463bb0 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/pipelines.vue @@ -0,0 +1,450 @@ +<!-- eslint-disable vue/multi-word-component-names --> +<script> +import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; +import { isEqual } from 'lodash'; +import * as Sentry from '@sentry/browser'; +import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert'; +import { getParameterByName } from '~/lib/utils/url_utility'; +import { __, s__ } from '~/locale'; +import Tracking from '~/tracking'; +import { + FILTER_TAG_IDENTIFIER, + PipelineKeyOptions, + RAW_TEXT_WARNING, + TRACKING_CATEGORIES, +} from '~/ci/constants'; +import PipelinesTableComponent from '~/ci/common/pipelines_table.vue'; +import PipelinesMixin from '~/ci/pipeline_details/mixins/pipelines_mixin'; +import { validateParams } from '~/ci/pipeline_details/utils'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; +import { isLoggedIn } from '~/lib/utils/common_utils'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import PipelinesService from './services/pipelines_service'; +import { ANY_TRIGGER_AUTHOR } from './constants'; +import NoCiEmptyState from './components/empty_state/no_ci_empty_state.vue'; +import NavigationControls from './components/nav_controls.vue'; +import PipelinesFilteredSearch from './components/pipelines_filtered_search.vue'; + +export default { + PipelineKeyOptions, + components: { + NoCiEmptyState, + GlCollapsibleListbox, + GlEmptyState, + GlIcon, + GlLoadingIcon, + NavigationTabs, + NavigationControls, + PipelinesFilteredSearch, + PipelinesTableComponent, + TablePagination, + }, + mixins: [PipelinesMixin, Tracking.mixin()], + props: { + store: { + type: Object, + required: true, + }, + // Can be rendered in 3 different places, with some visual differences + // Accepts root | child + // `root` -> main view + // `child` -> rendered inside MR or Commit View + viewType: { + type: String, + required: false, + default: 'root', + }, + endpoint: { + type: String, + required: true, + }, + pipelineScheduleUrl: { + type: String, + required: false, + default: '', + }, + emptyStateSvgPath: { + type: String, + required: true, + }, + errorStateSvgPath: { + type: String, + required: true, + }, + noPipelinesSvgPath: { + type: String, + required: true, + }, + hasGitlabCi: { + type: Boolean, + required: true, + }, + canCreatePipeline: { + type: Boolean, + required: true, + }, + ciLintPath: { + type: String, + required: false, + default: null, + }, + resetCachePath: { + type: String, + required: false, + default: null, + }, + newPipelinePath: { + type: String, + required: false, + default: null, + }, + projectId: { + type: String, + required: true, + }, + defaultBranchName: { + type: String, + required: false, + default: null, + }, + params: { + type: Object, + required: true, + }, + registrationToken: { + type: String, + required: false, + default: null, + }, + defaultVisibilityPipelineIdType: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + // Start with loading state to avoid a glitch when the empty state will be rendered + isLoading: true, + state: this.store.state, + scope: getParameterByName('scope') || 'all', + page: getParameterByName('page') || '1', + requestData: {}, + isResetCacheButtonLoading: false, + visibilityPipelineIdType: this.defaultVisibilityPipelineIdType, + }; + }, + stateMap: { + // with tabs + loading: 'loading', + tableList: 'tableList', + error: 'error', + emptyTab: 'emptyTab', + + // without tabs + emptyState: 'emptyState', + }, + scopes: { + all: 'all', + finished: 'finished', + branches: 'branches', + tags: 'tags', + }, + computed: { + /** + * `hasGitlabCi` handles both internal and external CI. + * The order on which the checks are made in this method is + * important to guarantee we handle all the corner cases. + */ + stateToRender() { + const { stateMap } = this.$options; + + if (this.isLoading) { + return stateMap.loading; + } + + if (this.hasError) { + return stateMap.error; + } + + if (this.state.pipelines.length) { + return stateMap.tableList; + } + + if ((this.scope !== 'all' && this.scope !== null) || this.hasGitlabCi) { + return stateMap.emptyTab; + } + + return stateMap.emptyState; + }, + /** + * Tabs are rendered in all states except empty state. + * They are not rendered before the first request to avoid a flicker on first load. + */ + shouldRenderTabs() { + const { stateMap } = this.$options; + return ( + this.hasMadeRequest && + [stateMap.loading, stateMap.tableList, stateMap.error, stateMap.emptyTab].includes( + this.stateToRender, + ) + ); + }, + + shouldRenderButtons() { + return ( + (this.newPipelinePath || this.resetCachePath || this.ciLintPath) && this.shouldRenderTabs + ); + }, + + shouldRenderPagination() { + return !this.isLoading && !this.hasError; + }, + + emptyTabMessage() { + if (this.scope === this.$options.scopes.finished) { + return s__('Pipelines|There are currently no finished pipelines.'); + } + + return s__('Pipelines|There are currently no pipelines.'); + }, + + tabs() { + const { count } = this.state; + const { scopes } = this.$options; + + return [ + { + name: __('All'), + scope: scopes.all, + count: count.all, + isActive: this.scope === 'all', + }, + { + name: __('Finished'), + scope: scopes.finished, + isActive: this.scope === 'finished', + }, + { + name: __('Branches'), + scope: scopes.branches, + isActive: this.scope === 'branches', + }, + { + name: __('Tags'), + scope: scopes.tags, + isActive: this.scope === 'tags', + }, + ]; + }, + validatedParams() { + return validateParams(this.params); + }, + selectedPipelineKeyOption() { + return ( + this.$options.PipelineKeyOptions.find((e) => this.visibilityPipelineIdType === e.value) || + this.$options.PipelineKeyOptions[0] + ); + }, + }, + created() { + this.service = new PipelinesService(this.endpoint); + this.requestData = { page: this.page, scope: this.scope, ...this.validatedParams }; + }, + methods: { + onChangeTab(scope) { + if (this.scope === scope) { + return; + } + + let params = { + scope, + page: '1', + }; + + params = this.onChangeWithFilter(params); + + this.updateContent(params); + + this.track('click_filter_tabs', { label: TRACKING_CATEGORIES.tabs, property: scope }); + }, + successCallback(resp) { + // Because we are polling & the user is interacting verify if the response received + // matches the last request made + if (isEqual(resp.config.params, this.requestData)) { + this.store.storeCount(resp.data.count); + this.store.storePagination(resp.headers); + this.setCommonData(resp.data.pipelines); + } + }, + handleResetRunnersCache(endpoint) { + this.isResetCacheButtonLoading = true; + + this.service + .postAction(endpoint) + .then(() => { + this.isResetCacheButtonLoading = false; + createAlert({ + message: s__('Pipelines|Project cache successfully reset.'), + variant: VARIANT_INFO, + }); + }) + .catch(() => { + this.isResetCacheButtonLoading = false; + createAlert({ + message: s__('Pipelines|Something went wrong while cleaning runners cache.'), + }); + }); + }, + resetRequestData() { + this.requestData = { page: this.page, scope: this.scope }; + }, + filterPipelines(filters) { + this.resetRequestData(); + + filters.forEach((filter) => { + // do not add Any for username query param, so we + // can fetch all trigger authors + if ( + filter.type && + filter.value.data !== ANY_TRIGGER_AUTHOR && + filter.type !== FILTER_TAG_IDENTIFIER + ) { + this.requestData[filter.type] = filter.value.data; + } + + if (filter.type === FILTER_TAG_IDENTIFIER) { + this.requestData.ref = filter.value.data; + } + + if (!filter.type) { + createAlert({ + message: RAW_TEXT_WARNING, + variant: VARIANT_WARNING, + }); + } + }); + + if (filters.length === 0) { + this.resetRequestData(); + } + + this.updateContent({ ...this.requestData, page: '1' }); + }, + changeVisibilityPipelineIDType(idType) { + this.visibilityPipelineIdType = idType; + this.saveVisibilityPipelineIDType(idType); + }, + saveVisibilityPipelineIDType(idType) { + if (!isLoggedIn()) return; + + this.$apollo + .mutate({ + mutation: setSortPreferenceMutation, + variables: { input: { visibilityPipelineIdType: idType.toUpperCase() } }, + }) + .then(({ data }) => { + if (data.userPreferencesUpdate.errors.length) { + throw new Error(data.userPreferencesUpdate.errors); + } + }) + .catch((error) => { + Sentry.captureException(error); + }); + }, + }, +}; +</script> +<template> + <div class="pipelines-container"> + <div + v-if="shouldRenderTabs || shouldRenderButtons" + class="top-area scrolling-tabs-container inner-page-scroll-tabs gl-border-none" + > + <div class="fade-left"><gl-icon name="chevron-lg-left" :size="12" /></div> + <div class="fade-right"><gl-icon name="chevron-lg-right" :size="12" /></div> + + <navigation-tabs + v-if="shouldRenderTabs" + :tabs="tabs" + scope="pipelines" + @onChangeTab="onChangeTab" + /> + + <navigation-controls + v-if="shouldRenderButtons" + :new-pipeline-path="newPipelinePath" + :reset-cache-path="resetCachePath" + :ci-lint-path="ciLintPath" + :is-reset-cache-button-loading="isResetCacheButtonLoading" + @resetRunnersCache="handleResetRunnersCache" + /> + </div> + + <div v-if="stateToRender !== $options.stateMap.emptyState" class="gl-display-flex"> + <div class="row-content-block gl-display-flex gl-flex-grow-1 gl-border-b-0"> + <pipelines-filtered-search + class="gl-display-flex gl-flex-grow-1 gl-mr-4" + :project-id="projectId" + :default-branch-name="defaultBranchName" + :params="validatedParams" + @filterPipelines="filterPipelines" + /> + <gl-collapsible-listbox + v-model="visibilityPipelineIdType" + data-testid="pipeline-key-collapsible-box" + :toggle-text="selectedPipelineKeyOption.text" + :items="$options.PipelineKeyOptions" + @select="changeVisibilityPipelineIDType" + /> + </div> + </div> + + <div class="content-list pipelines"> + <gl-loading-icon + v-if="stateToRender === $options.stateMap.loading" + :label="s__('Pipelines|Loading Pipelines')" + size="lg" + class="prepend-top-20" + /> + + <no-ci-empty-state + v-else-if="stateToRender === $options.stateMap.emptyState" + :empty-state-svg-path="emptyStateSvgPath" + :can-set-ci="canCreatePipeline" + :registration-token="registrationToken" + /> + + <gl-empty-state + v-else-if="stateToRender === $options.stateMap.error" + :svg-path="errorStateSvgPath" + :title="s__('Pipelines|There was an error fetching the pipelines.')" + :description="s__('Pipelines|Try again in a few moments or contact your support team.')" + /> + + <gl-empty-state + v-else-if="stateToRender === $options.stateMap.emptyTab" + :svg-path="noPipelinesSvgPath" + :svg-height="150" + :title="emptyTabMessage" + /> + + <div v-else-if="stateToRender === $options.stateMap.tableList"> + <pipelines-table-component + :pipelines="state.pipelines" + :pipeline-schedule-url="pipelineScheduleUrl" + :update-graph-dropdown="updateGraphDropdown" + :view-type="viewType" + :pipeline-key-option="selectedPipelineKeyOption" + /> + </div> + + <table-pagination + v-if="shouldRenderPagination" + :change="onChangePage" + :page-info="state.pageInfo" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js new file mode 100644 index 00000000000..c38fa07c7e3 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/services/pipelines_service.js @@ -0,0 +1,51 @@ +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import { validateParams } from '../../pipeline_details/utils'; + +export default class PipelinesService { + /** + * Commits and merge request endpoints need to be requested with `.json`. + * + * The url provided to request the pipelines in the new merge request + * page already has `.json`. + * + * @param {String} root + */ + constructor(root) { + if (root.indexOf('.json') === -1) { + this.endpoint = `${root}.json`; + } else { + this.endpoint = root; + } + } + + getPipelines(data = {}) { + const { scope, page } = data; + const { CancelToken } = axios; + + const queryParams = { scope, page, ...validateParams(data) }; + + this.cancelationSource = CancelToken.source(); + + return axios.get(this.endpoint, { + params: queryParams, + cancelToken: this.cancelationSource.token, + }); + } + + /** + * Post request for all pipelines actions. + * + * @param {String} endpoint + * @return {Promise} + */ + // eslint-disable-next-line class-methods-use-this + postAction(endpoint) { + return axios.post(`${endpoint}.json`); + } + + // eslint-disable-next-line class-methods-use-this + runMRPipeline({ projectId, mergeRequestId }) { + return Api.postMergeRequestPipeline(projectId, { mergeRequestId }); + } +} diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/constants.js b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js new file mode 100644 index 00000000000..d8f15cfde91 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/constants.js @@ -0,0 +1,52 @@ +import { s__ } from '~/locale'; + +export const PIPELINE_SOURCES = [ + { + text: s__('PipelineSource|Push'), + value: 'push', + }, + { + text: s__('PipelineSource|Web'), + value: 'web', + }, + { + text: s__('PipelineSource|Trigger'), + value: 'trigger', + }, + { + text: s__('PipelineSource|Schedule'), + value: 'schedule', + }, + { + text: s__('PipelineSource|API'), + value: 'api', + }, + { + text: s__('PipelineSource|External'), + value: 'external', + }, + { + text: s__('PipelineSource|Pipeline'), + value: 'pipeline', + }, + { + text: s__('PipelineSource|Chat'), + value: 'chat', + }, + { + text: s__('PipelineSource|Web IDE'), + value: 'webide', + }, + { + text: s__('PipelineSource|Merge Request'), + value: 'merge_request_event', + }, + { + text: s__('PipelineSource|External Pull Request'), + value: 'external_pull_request_event', + }, + { + text: s__('PipelineSource|Parent Pipeline'), + value: 'parent_pipeline', + }, +]; diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue new file mode 100644 index 00000000000..45b6fb380a9 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_branch_name_token.vue @@ -0,0 +1,83 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + branches: null, + loading: true, + }; + }, + created() { + this.fetchBranches(); + }, + methods: { + fetchBranches(searchterm) { + Api.branches(this.config.projectId, searchterm) + .then(({ data }) => { + this.branches = data.map((branch) => branch.name); + if (!searchterm && this.config.defaultBranchName) { + // Shift the default branch to the top of the list + this.branches = this.branches.filter( + (branch) => branch !== this.config.defaultBranchName, + ); + this.branches.unshift(this.config.defaultBranchName); + } + this.loading = false; + }) + .catch((err) => { + createAlert({ + message: __('There was a problem fetching project branches.'), + }); + this.loading = false; + throw err; + }); + }, + searchBranches: debounce(function debounceSearch({ data }) { + this.fetchBranches(data); + }, FILTER_PIPELINES_SEARCH_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchBranches" + > + <template #suggestions> + <gl-loading-icon v-if="loading" size="sm" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="(branch, index) in branches" + :key="index" + :value="branch" + > + {{ branch }} + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue new file mode 100644 index 00000000000..b4b5c5c1b37 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_source_token.vue @@ -0,0 +1,47 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { PIPELINE_SOURCES } from 'ee_else_ce/ci/pipelines_page/tokens/constants'; + +export default { + PIPELINE_SOURCES, + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + activeSource() { + return PIPELINE_SOURCES.find((source) => source.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <span>{{ activeSource.text }}</span> + </div> + </template> + + <template #suggestions> + <gl-filtered-search-suggestion + v-for="source in $options.PIPELINE_SOURCES" + :key="source.value" + :value="source.value" + > + {{ source.text }} + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue new file mode 100644 index 00000000000..020a08b8cee --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_status_token.vue @@ -0,0 +1,104 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + computed: { + statuses() { + return [ + { + class: 'ci-status-icon-canceled', + icon: 'status_canceled', + text: s__('Pipeline|Canceled'), + value: 'canceled', + }, + { + class: 'ci-status-icon-created', + icon: 'status_created', + text: s__('Pipeline|Created'), + value: 'created', + }, + { + class: 'ci-status-icon-failed', + icon: 'status_failed', + text: s__('Pipeline|Failed'), + value: 'failed', + }, + { + class: 'ci-status-icon-manual', + icon: 'status_manual', + text: s__('Pipeline|Manual'), + value: 'manual', + }, + { + class: 'ci-status-icon-success', + icon: 'status_success', + text: s__('Pipeline|Passed'), + value: 'success', + }, + { + class: 'ci-status-icon-pending', + icon: 'status_pending', + text: s__('Pipeline|Pending'), + value: 'pending', + }, + { + class: 'ci-status-icon-running', + icon: 'status_running', + text: s__('Pipeline|Running'), + value: 'running', + }, + { + class: 'ci-status-icon-skipped', + icon: 'status_skipped', + text: s__('Pipeline|Skipped'), + value: 'skipped', + }, + ]; + }, + findActiveStatus() { + return this.statuses.find((status) => status.value === this.value.data); + }, + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners"> + <template #view> + <div class="gl-display-flex gl-align-items-center"> + <div :class="findActiveStatus.class"> + <gl-icon :name="findActiveStatus.icon" class="gl-mr-2 gl-display-block" /> + </div> + <span>{{ findActiveStatus.text }}</span> + </div> + </template> + <template #suggestions> + <gl-filtered-search-suggestion + v-for="(status, index) in statuses" + :key="index" + :value="status.value" + > + <div class="gl-display-flex" :class="status.class"> + <gl-icon :name="status.icon" class="gl-mr-3" /> + <span>{{ status.text }}</span> + </div> + </gl-filtered-search-suggestion> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue new file mode 100644 index 00000000000..a6034e78b6d --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_tag_name_token.vue @@ -0,0 +1,67 @@ +<script> +import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; + +export default { + components: { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + tags: null, + loading: true, + }; + }, + created() { + this.fetchTags(); + }, + methods: { + fetchTags(searchTerm) { + Api.tags(this.config.projectId, searchTerm) + .then(({ data }) => { + this.tags = data.map((tag) => tag.name); + this.loading = false; + }) + .catch((err) => { + createAlert({ + message: __('There was a problem fetching project tags.'), + }); + this.loading = false; + throw err; + }); + }, + searchTags: debounce(function debounceSearch({ data }) { + this.fetchTags(data); + }, FILTER_PIPELINES_SEARCH_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token v-bind="{ ...$props, ...$attrs }" v-on="$listeners" @input="searchTags"> + <template #suggestions> + <gl-loading-icon v-if="loading" size="sm" /> + <template v-else> + <gl-filtered-search-suggestion v-for="(tag, index) in tags" :key="index" :value="tag"> + {{ tag }} + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue new file mode 100644 index 00000000000..20c5e1557a7 --- /dev/null +++ b/app/assets/javascripts/ci/pipelines_page/tokens/pipeline_trigger_author_token.vue @@ -0,0 +1,110 @@ +<script> +import { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, +} from '@gitlab/ui'; +import { debounce } from 'lodash'; +import Api from '~/api'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import { ANY_TRIGGER_AUTHOR, FILTER_PIPELINES_SEARCH_DELAY } from '../constants'; + +export default { + anyTriggerAuthor: ANY_TRIGGER_AUTHOR, + components: { + GlFilteredSearchToken, + GlAvatar, + GlFilteredSearchSuggestion, + GlDropdownDivider, + GlLoadingIcon, + }, + props: { + config: { + type: Object, + required: true, + }, + value: { + type: Object, + required: true, + }, + }, + data() { + return { + users: [], + loading: true, + }; + }, + computed: { + currentValue() { + return this.value.data.toLowerCase(); + }, + activeUser() { + return this.users.find((user) => { + return user.username.toLowerCase() === this.currentValue; + }); + }, + }, + created() { + this.fetchProjectUsers(); + }, + methods: { + fetchProjectUsers(searchTerm) { + Api.projectUsers(this.config.projectId, searchTerm) + .then((users) => { + this.users = users; + this.loading = false; + }) + .catch((err) => { + createAlert({ + message: __('There was a problem fetching project users.'), + }); + this.loading = false; + throw err; + }); + }, + searchAuthors: debounce(function debounceSearch({ data }) { + this.fetchProjectUsers(data); + }, FILTER_PIPELINES_SEARCH_DELAY), + }, +}; +</script> + +<template> + <gl-filtered-search-token + :config="config" + v-bind="{ ...$props, ...$attrs }" + v-on="$listeners" + @input="searchAuthors" + > + <template #view="{ inputValue }"> + <gl-avatar v-if="activeUser" :size="16" :src="activeUser.avatar_url" class="gl-mr-2" /> + <span>{{ activeUser ? activeUser.name : inputValue }}</span> + </template> + <template #suggestions> + <gl-filtered-search-suggestion :value="$options.anyTriggerAuthor">{{ + $options.anyTriggerAuthor + }}</gl-filtered-search-suggestion> + <gl-dropdown-divider /> + + <gl-loading-icon v-if="loading" size="sm" /> + <template v-else> + <gl-filtered-search-suggestion + v-for="user in users" + :key="user.username" + :value="user.username" + > + <div class="d-flex"> + <gl-avatar :size="32" :src="user.avatar_url" /> + <div> + <div>{{ user.name }}</div> + <div>@{{ user.username }}</div> + </div> + </div> + </gl-filtered-search-suggestion> + </template> + </template> + </gl-filtered-search-token> +</template> diff --git a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue index bd41b8d23f1..f2346a5512e 100644 --- a/app/assets/javascripts/ci/reports/components/issue_status_icon.vue +++ b/app/assets/javascripts/ci/reports/components/issue_status_icon.vue @@ -22,7 +22,8 @@ export default { iconName() { if (this.isStatusFailed) { return 'status_failed_borderless'; - } else if (this.isStatusSuccess) { + } + if (this.isStatusSuccess) { return 'status_success_borderless'; } @@ -49,6 +50,6 @@ export default { }" class="report-block-list-icon" > - <gl-icon :name="iconName" :size="statusIconSize" :data-qa-selector="`status_${status}_icon`" /> + <gl-icon :name="iconName" :size="statusIconSize" /> </div> </template> diff --git a/app/assets/javascripts/ci/reports/components/report_section.vue b/app/assets/javascripts/ci/reports/components/report_section.vue index a4ec7b6a325..fd6c6cca6b7 100644 --- a/app/assets/javascripts/ci/reports/components/report_section.vue +++ b/app/assets/javascripts/ci/reports/components/report_section.vue @@ -159,7 +159,8 @@ export default { slotName() { if (this.isSuccess) { return SLOT_SUCCESS; - } else if (this.isLoading) { + } + if (this.isLoading) { return SLOT_LOADING; } diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index e6813211fe9..0ec94dc865f 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -178,6 +178,22 @@ export default { </script> <template> <div> + <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> + <h2 class="gl-my-0 header-title"> + {{ s__('Runners|Runners') }} + </h2> + <div class="gl-display-flex gl-gap-3"> + <runner-dashboard-link /> + <gl-button :href="newRunnerPath" variant="confirm"> + {{ s__('Runners|New instance runner') }} + </gl-button> + <registration-dropdown + :registration-token="registrationToken" + :type="$options.INSTANCE_TYPE" + placement="right" + /> + </div> + </header> <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -189,18 +205,6 @@ export default { content-class="gl-display-none" nav-class="gl-border-none!" /> - - <div class="gl-w-full gl-md-w-auto gl-display-flex gl-gap-3"> - <runner-dashboard-link /> - <gl-button :href="newRunnerPath" variant="confirm"> - {{ s__('Runners|New instance runner') }} - </gl-button> - <registration-dropdown - :registration-token="registrationToken" - :type="$options.INSTANCE_TYPE" - placement="right" - /> - </div> </div> <runner-filtered-search-bar diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue index cb43760b2d6..8f1c7234b84 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_owner_cell.vue @@ -50,12 +50,7 @@ export default { <template> <div> - <gl-link - v-if="cell.href" - v-gl-tooltip="cell.tooltip" - :href="cell.href" - class="gl-text-body gl-text-decoration-underline" - > + <gl-link v-if="cell.href" v-gl-tooltip="cell.tooltip" :href="cell.href" class="gl-text-body"> {{ cell.text }} </gl-link> <span v-else>{{ cell.text }}</span> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index cc31afea88c..a80d6207be8 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -12,7 +12,6 @@ import RunnerManagersBadge from '../runner_managers_badge.vue'; import { formatJobCount } from '../../utils'; import { - I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -73,7 +72,6 @@ export default { formatNumber, }, i18n: { - I18N_NO_DESCRIPTION, I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, @@ -100,7 +98,10 @@ export default { <runner-type-badge :type="runner.runnerType" size="sm" class="gl-vertical-align-middle" /> </div> - <div class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full"> + <div + v-if="runner.version || runner.description" + class="gl-mb-3 gl-ml-auto gl-display-inline-flex gl-max-w-full gl-font-sm gl-align-items-center" + > <template v-if="runner.version"> <div class="gl-flex-shrink-0"> <runner-upgrade-status-icon :upgrade-status="runner.upgradeStatus" /> @@ -108,19 +109,20 @@ export default { <template #version>{{ runner.version }}</template> </gl-sprintf> </div> - <div class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> + <div v-if="runner.description" class="gl-text-secondary gl-mx-2" aria-hidden="true">·</div> </template> <tooltip-on-truncate + v-if="runner.description" class="gl-text-truncate gl-display-block" :class="{ 'gl-text-secondary': !runner.description }" :title="runner.description" > - {{ runner.description || $options.i18n.I18N_NO_DESCRIPTION }} + {{ runner.description }} </tooltip-on-truncate> </div> - <div> - <runner-summary-field icon="clock"> + <div class="gl-font-sm"> + <runner-summary-field icon="clock" icon-size="sm"> <gl-sprintf :message="$options.i18n.I18N_LAST_CONTACT_LABEL"> <template #timeAgo> <time-ago v-if="runner.contactedAt" :time="runner.contactedAt" /> diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue index 742259ee491..b1b61e03eec 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_field.vue @@ -25,7 +25,7 @@ export default { <template> <div v-gl-tooltip="tooltip" class="gl-display-inline-block gl-text-secondary gl-mb-3 gl-mr-4"> - <gl-icon v-if="icon" :name="icon" /> + <gl-icon v-if="icon" :name="icon" :size="12" /> <!-- display tooltip as a label for screen readers --> <span class="gl-sr-only">{{ tooltip }}</span> <slot></slot> diff --git a/app/assets/javascripts/ci/runner/components/runner_create_form.vue b/app/assets/javascripts/ci/runner/components/runner_create_form.vue index 1b363174d28..adaed77055a 100644 --- a/app/assets/javascripts/ci/runner/components/runner_create_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_create_form.vue @@ -120,7 +120,7 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <runner-form-fields v-model="runner" /> + <runner-form-fields v-model="runner" :runner-type="runnerType" /> <div class="gl-display-flex gl-mt-6"> <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="saving"> diff --git a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue index 3634dcf1c93..81b2a17631e 100644 --- a/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/ci/runner/components/runner_filtered_search_bar.vue @@ -92,7 +92,6 @@ export default { :initial-filter-value="initialFilterValue" :tokens="validTokens" :initial-sort-by="initialSortBy" - :search-input-placeholder="__('Search or filter results...')" :search-text-option-label="s__('Runners|Search description...')" terms-as-tokens data-testid="runners-filtered-search" diff --git a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue index d090a562ff7..38e36733045 100644 --- a/app/assets/javascripts/ci/runner/components/runner_form_fields.vue +++ b/app/assets/javascripts/ci/runner/components/runner_form_fields.vue @@ -10,7 +10,12 @@ import { GlSkeletonLoader, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { ACCESS_LEVEL_NOT_PROTECTED, ACCESS_LEVEL_REF_PROTECTED, PROJECT_TYPE } from '../constants'; +import { + ACCESS_LEVEL_NOT_PROTECTED, + ACCESS_LEVEL_REF_PROTECTED, + PROJECT_TYPE, + RUNNER_TYPES, +} from '../constants'; export default { name: 'RunnerFormFields', @@ -26,6 +31,12 @@ export default { import('ee_component/ci/runner/components/runner_maintenance_note_field.vue'), }, props: { + runnerType: { + type: String, + required: false, + default: null, + validator: (t) => RUNNER_TYPES.includes(t), + }, value: { type: Object, default: null, @@ -44,7 +55,7 @@ export default { }, computed: { canBeLockedToProject() { - return this.value?.runnerType === PROJECT_TYPE; + return this.runnerType === PROJECT_TYPE; }, }, watch: { diff --git a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue index 10790c398b0..58244e1f2df 100644 --- a/app/assets/javascripts/ci/runner/components/runner_managers_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_managers_table.vue @@ -6,6 +6,7 @@ import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; import { I18N_STATUS_NEVER_CONTACTED } from '../constants'; import RunnerStatusBadge from './runner_status_badge.vue'; +import RunnerJobStatusBadge from './runner_job_status_badge.vue'; export default { name: 'RunnerManagersTable', @@ -15,6 +16,7 @@ export default { HelpPopover, GlIntersperse, RunnerStatusBadge, + RunnerJobStatusBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), }, @@ -52,7 +54,15 @@ export default { </help-popover> </template> <template #cell(status)="{ item = {} }"> - <runner-status-badge :contacted-at="item.contactedAt" :status="item.status" /> + <runner-status-badge + class="gl-vertical-align-middle" + :contacted-at="item.contactedAt" + :status="item.status" + /> + <runner-job-status-badge + class="gl-vertical-align-middle" + :job-status="item.jobExecutionStatus" + /> </template> <template #cell(version)="{ item = {} }"> {{ item.version }} diff --git a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue index a49641194a7..a841f66b566 100644 --- a/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue +++ b/app/assets/javascripts/ci/runner/components/runner_platforms_radio_group.vue @@ -55,7 +55,7 @@ export default { <div class="gl-mt-3 gl-mb-6"> <label>{{ s__('Runners|Operating systems') }}</label> - <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <runner-platforms-radio v-model="model" @@ -76,7 +76,7 @@ export default { <div class="gl-mt-3 gl-mb-6"> <label>{{ s__('Runners|Containers') }}</label> - <div class="gl-display-flex gl-flex-wrap gl-gap-5"> + <div class="gl-display-flex gl-flex-wrap gl-gap-3"> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> <runner-platforms-radio :image="$options.DOCKER_LOGO_URL"> <gl-link :href="$options.DOCKER_HELP_URL" target="_blank"> diff --git a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue index 70226074993..3af10c59e31 100644 --- a/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue +++ b/app/assets/javascripts/ci/runner/components/runner_type_tabs.vue @@ -112,7 +112,12 @@ export default { :scope="countScope" :variables="tabBadgeCountVariables(tab.runnerType)" > - <gl-badge v-if="tabCount(count)" class="gl-ml-1" size="sm"> + <gl-badge + v-if="tabCount(count)" + class="gl-ml-1" + size="sm" + :data-testid="`runner-count-${tab.title.toLowerCase()}`" + > {{ tabCount(count) }} </gl-badge> </runner-count> diff --git a/app/assets/javascripts/ci/runner/components/runner_update_form.vue b/app/assets/javascripts/ci/runner/components/runner_update_form.vue index 6b94e594f1c..4278615ba66 100644 --- a/app/assets/javascripts/ci/runner/components/runner_update_form.vue +++ b/app/assets/javascripts/ci/runner/components/runner_update_form.vue @@ -96,7 +96,7 @@ export default { </script> <template> <gl-form @submit.prevent="onSubmit"> - <runner-form-fields v-model="model" :loading="loading" /> + <runner-form-fields v-model="model" :loading="loading" :runner-type="runnerType" /> <runner-update-cost-factor-fields v-model="model" :runner-type="runnerType" /> <div class="gl-mt-6"> diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue index c33c42f3afe..cee1088d90b 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_count.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_count.vue @@ -55,7 +55,8 @@ export default { query() { if (this.scope === INSTANCE_TYPE) { return allRunnersCountQuery; - } else if (this.scope === GROUP_TYPE) { + } + if (this.scope === GROUP_TYPE) { return groupRunnersCountQuery; } return null; @@ -74,7 +75,8 @@ export default { update(data) { if (this.scope === INSTANCE_TYPE) { return data?.runners?.count; - } else if (this.scope === GROUP_TYPE) { + } + if (this.scope === GROUP_TYPE) { return data?.group?.runners?.count; } return null; diff --git a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue index e0a6f4b1e67..6c49263ac82 100644 --- a/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue +++ b/app/assets/javascripts/ci/runner/components/stat/runner_stats.vue @@ -90,6 +90,7 @@ export default { :scope="scope" v-bind="stat.props" class="gl-px-5" + :data-testid="`runner-stats-${stat.key.toLowerCase()}`" /> <runner-upgrade-status-stats diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index 203f97876de..3293c68ddb8 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -15,7 +15,7 @@ export const I18N_CREATE_ERROR = s__( ); export const FILTER_CSS_CLASSES = - 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1 gl-border-b-1'; + 'gl-bg-gray-10 gl-p-5 gl-border-solid gl-border-gray-100 gl-border-0 gl-border-t-1'; // Type @@ -96,7 +96,6 @@ export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); // List -export const I18N_NO_DESCRIPTION = s__('Runners|No description'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql index ead005d1252..84d32e24f24 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_manager_shared.fragment.graphql @@ -9,4 +9,5 @@ fragment CiRunnerManagerShared on CiRunnerManager { platformName ipAddress contactedAt + jobExecutionStatus } diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index 71584c40a38..dcaf8635f5c 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -212,6 +212,27 @@ export default { <template> <div> + <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> + <h2 class="gl-my-0 header-title"> + {{ s__('Runners|Runners') }} + </h2> + <div class="gl-display-flex gl-gap-3"> + <gl-button + v-if="newRunnerPath" + :href="newRunnerPath" + variant="confirm" + data-testid="new-group-runner-button" + > + {{ s__('Runners|New group runner') }} + </gl-button> + <registration-dropdown + v-if="registrationToken" + :registration-token="registrationToken" + :type="$options.GROUP_TYPE" + placement="right" + /> + </div> + </header> <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > @@ -225,19 +246,6 @@ export default { content-class="gl-display-none" nav-class="gl-border-none!" /> - - <div class="gl-w-full gl-md-w-auto gl-display-flex"> - <gl-button v-if="newRunnerPath" :href="newRunnerPath" variant="confirm"> - {{ s__('Runners|New group runner') }} - </gl-button> - <registration-dropdown - v-if="registrationToken" - class="gl-ml-3" - :registration-token="registrationToken" - :type="$options.GROUP_TYPE" - placement="right" - /> - </div> </div> <div class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-gap-3" diff --git a/app/assets/javascripts/ci/runner/project_runners/index.js b/app/assets/javascripts/ci/runner/project_runners/index.js deleted file mode 100644 index 3be2b4a7422..00000000000 --- a/app/assets/javascripts/ci/runner/project_runners/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import ProjectRunnersApp from './project_runners_app.vue'; - -export const initProjectRunners = (selector = '#js-project-runners') => { - const el = document.querySelector(selector); - - if (!el) { - return null; - } - - const { projectFullPath } = el.dataset; - - return new Vue({ - el, - render(h) { - return h(ProjectRunnersApp, { - props: { - projectFullPath, - }, - }); - }, - }); -}; diff --git a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue b/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue deleted file mode 100644 index c7bf5e521a1..00000000000 --- a/app/assets/javascripts/ci/runner/project_runners/project_runners_app.vue +++ /dev/null @@ -1,19 +0,0 @@ -<script> -export default { - props: { - projectFullPath: { - required: true, - type: String, - }, - }, -}; -</script> -<template> - <div> - <!-- - Under development - Issue: https://gitlab.com/gitlab-org/gitlab/-/issues/33803 - Feature rollout: https://gitlab.com/gitlab-org/gitlab/-/issues/386573 - --> - </div> -</template> diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index 3dc99baa329..8915198350f 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -97,7 +97,8 @@ const outdatedStatusParams = (status) => { [PARAM_KEY_PAUSED]: ['false'], [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! }; - } else if (status === STATUS_PAUSED) { + } + if (status === STATUS_PAUSED) { return { [PARAM_KEY_PAUSED]: ['true'], [PARAM_KEY_STATUS]: [], // Important! clear PARAM_KEY_STATUS to avoid a redirection loop! diff --git a/app/assets/javascripts/ci/utils.js b/app/assets/javascripts/ci/utils.js new file mode 100644 index 00000000000..eb9e9538b75 --- /dev/null +++ b/app/assets/javascripts/ci/utils.js @@ -0,0 +1,17 @@ +import * as Sentry from '@sentry/browser'; + +export const reportToSentry = (component, failureType) => { + Sentry.withScope((scope) => { + scope.setTag('component', component); + Sentry.captureException(failureType); + }); +}; + +export const reportMessageToSentry = (component, message, context) => { + Sentry.withScope((scope) => { + // eslint-disable-next-line @gitlab/require-i18n-strings + scope.setContext('Vue data', context); + scope.setTag('component', component); + Sentry.captureMessage(message); + }); +}; |