diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components')
21 files changed, 745 insertions, 136 deletions
diff --git a/app/assets/javascripts/pipelines/components/dag/dag.vue b/app/assets/javascripts/pipelines/components/dag/dag.vue index 16fb931ec2b..475dd3bf36e 100644 --- a/app/assets/javascripts/pipelines/components/dag/dag.vue +++ b/app/assets/javascripts/pipelines/components/dag/dag.vue @@ -238,7 +238,7 @@ export default { </div> </template> <template v-if="dagDocPath" #actions> - <gl-button :href="dagDocPath" target="__blank" variant="success"> + <gl-button :href="dagDocPath" target="_blank" variant="confirm"> {{ $options.emptyStateTexts.button }} </gl-button> </template> diff --git a/app/assets/javascripts/pipelines/components/graph/constants.js b/app/assets/javascripts/pipelines/components/graph/constants.js index 0b59612b25c..85ca52f633e 100644 --- a/app/assets/javascripts/pipelines/components/graph/constants.js +++ b/app/assets/javascripts/pipelines/components/graph/constants.js @@ -15,4 +15,8 @@ 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'; diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component.vue b/app/assets/javascripts/pipelines/components/graph/graph_component.vue index 015f0519c72..31a34ab4fb5 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component.vue @@ -233,6 +233,7 @@ export default { :view-type="viewType" @downstreamHovered="setSourceJob" @pipelineExpandToggle="togglePipelineExpanded" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" @scrollContainer="slidePipelineContainer" @error="onError" /> diff --git a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue index 534ad25a35d..f822e2c0874 100644 --- a/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue +++ b/app/assets/javascripts/pipelines/components/graph/graph_component_wrapper.vue @@ -8,7 +8,7 @@ import { DEFAULT, DRAW_FAILURE, LOAD_FAILURE } from '../../constants'; import DismissPipelineGraphCallout from '../../graphql/mutations/dismiss_pipeline_notification.graphql'; import getPipelineQuery from '../../graphql/queries/get_pipeline_header_data.query.graphql'; import { reportToSentry, reportMessageToSentry } from '../../utils'; -import { IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; +import { ACTION_FAILURE, IID_FAILURE, LAYER_VIEW, STAGE_VIEW, VIEW_TYPE_KEY } from './constants'; import PipelineGraph from './graph_component.vue'; import GraphViewSelector from './graph_view_selector.vue'; import { @@ -57,13 +57,29 @@ export default { showLinks: false, }; }, - errorTexts: { - [DRAW_FAILURE]: __('An error occurred while drawing job relationship links.'), - [IID_FAILURE]: __( - 'The data in this pipeline is too old to be rendered as a graph. Please check the Jobs tab to access historical data.', - ), - [LOAD_FAILURE]: __('We are currently unable to fetch data for this pipeline.'), - [DEFAULT]: __('An unknown error occurred while loading this graph.'), + 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: { @@ -154,28 +170,12 @@ export default { }, computed: { alert() { - switch (this.alertType) { - case DRAW_FAILURE: - return { - text: this.$options.errorTexts[DRAW_FAILURE], - variant: 'danger', - }; - case IID_FAILURE: - return { - text: this.$options.errorTexts[IID_FAILURE], - variant: 'info', - }; - case LOAD_FAILURE: - return { - text: this.$options.errorTexts[LOAD_FAILURE], - variant: 'danger', - }; - default: - return { - text: this.$options.errorTexts[DEFAULT], - variant: 'danger', - }; - } + const { errors } = this.$options; + + return { + text: errors[this.alertType]?.text ?? errors[DEFAULT].text, + variant: errors[this.alertType]?.variant ?? errors[DEFAULT].variant, + }; }, configPaths() { return { diff --git a/app/assets/javascripts/pipelines/components/graph/job_item.vue b/app/assets/javascripts/pipelines/components/graph/job_item.vue index f69b25dfa7c..362571930d6 100644 --- a/app/assets/javascripts/pipelines/components/graph/job_item.vue +++ b/app/assets/javascripts/pipelines/components/graph/job_item.vue @@ -1,5 +1,5 @@ <script> -import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui'; import delayedJobMixin from '~/jobs/mixins/delayed_job_mixin'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { sprintf, __ } from '~/locale'; @@ -7,7 +7,7 @@ import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '../../utils'; import ActionComponent from '../jobs_shared/action_component.vue'; import JobNameComponent from '../jobs_shared/job_name_component.vue'; -import { SINGLE_JOB } from './constants'; +import { BRIDGE_KIND, SINGLE_JOB } from './constants'; /** * Renders the badge for the pipeline graph and the job's dropdown. @@ -35,11 +35,16 @@ import { SINGLE_JOB } from './constants'; */ export default { + i18n: { + bridgeBadgeText: __('Trigger job'), + unauthorizedTooltip: __('You are not authorized to run this manual job'), + }, hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, CiIcon, JobNameComponent, + GlBadge, GlLink, }, directives: { @@ -113,6 +118,12 @@ export default { isSingleItem() { return this.type === SINGLE_JOB; }, + isBridge() { + return this.kind === BRIDGE_KIND; + }, + kind() { + return this.job?.kind || ''; + }, nameComponent() { return this.hasDetails ? 'gl-link' : 'div'; }, @@ -187,6 +198,7 @@ export default { [this.$options.hoverClass]: this.relatedDownstreamHovered || this.relatedDownstreamExpanded, }, + { 'gl-rounded-lg': this.isBridge }, this.cssClassJobName, ]; }, @@ -213,9 +225,6 @@ export default { this.$emit('pipelineActionRequestComplete'); }, }, - i18n: { - unauthorizedTooltip: __('You are not authorized to run this manual job'), - }, }; </script> <template> @@ -253,6 +262,9 @@ export default { </div> </div> </div> + <gl-badge v-if="isBridge" class="gl-mt-3" variant="info" size="sm"> + {{ $options.i18n.bridgeBadgeText }} + </gl-badge> </component> <action-component diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue index d59802196af..9f76d4cec50 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipeline.vue @@ -1,10 +1,22 @@ <script> -import { GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import { + GlBadge, + GlButton, + GlLink, + GlLoadingIcon, + GlTooltip, + GlTooltipDirective, +} from '@gitlab/ui'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; +import CancelPipelineMutation from '~/pipelines/graphql/mutations/cancel_pipeline.mutation.graphql'; +import RetryPipelineMutation from '~/pipelines/graphql/mutations/retry_pipeline.mutation.graphql'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { PIPELINE_GRAPHQL_TYPE } from '../../constants'; import { reportToSentry } from '../../utils'; -import { DOWNSTREAM, UPSTREAM } from './constants'; +import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from './constants'; export default { directives: { @@ -16,7 +28,14 @@ export default { 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!'], + }, + mixins: [glFeatureFlagMixin()], props: { columnTitle: { type: String, @@ -39,15 +58,44 @@ export default { required: true, }, }, + data() { + return { + hasActionTooltip: false, + isActionLoading: false, + }; + }, computed: { - buttonBorderClass() { - return this.isUpstream ? 'gl-border-r-1!' : 'gl-border-l-1!'; + action() { + if (this.glFeatures?.downstreamRetryAction && this.isDownstream) { + if (this.isCancelable) { + return { + icon: 'cancel', + method: this.cancelPipeline, + ariaLabel: __('Cancel downstream pipeline'), + }; + } else 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]; }, buttonId() { return `js-linked-pipeline-${this.pipeline.id}`; }, - cardSpacingClass() { - return this.isDownstream ? 'gl-pr-0' : ''; + cardClasses() { + return this.isDownstream + ? this.$options.styles.flatRightBorder + : this.$options.styles.flatLeftBorder; }, expandedIcon() { if (this.isUpstream) { @@ -64,9 +112,21 @@ export default { flexDirection() { return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; }, + graphqlPipelineId() { + return convertToGraphQLId(PIPELINE_GRAPHQL_TYPE, 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; }, @@ -93,13 +153,19 @@ export default { projectName() { return this.pipeline.project.name; }, + showAction() { + return Boolean(this.action?.method && this.action?.icon && this.action?.ariaLabel); + }, + showCardTooltip() { + return !this.hasActionTooltip; + }, sourceJobName() { return this.pipeline.sourceJob?.name ?? ''; }, sourceJobInfo() { return this.isDownstream ? sprintf(__('Created by %{job}'), { job: this.sourceJobName }) : ''; }, - tooltipText() { + cardTooltipText() { return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - ${this.sourceJobInfo}`; }, @@ -108,6 +174,26 @@ export default { 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); }, @@ -122,6 +208,12 @@ export default { onDownstreamHoverLeave() { this.$emit('downstreamHovered', ''); }, + retryPipeline() { + this.executePipelineAction(RetryPipelineMutation); + }, + setActionTooltip(flag) { + this.hasActionTooltip = flag; + }, }, }; </script> @@ -129,33 +221,48 @@ export default { <template> <div ref="linkedPipeline" - v-gl-tooltip - class="gl-h-full gl-display-flex! gl-border-solid gl-border-gray-100 gl-border-1" + class="gl-h-full gl-display-flex!" :class="flexDirection" - :title="tooltipText" data-qa-selector="linked_pipeline_container" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > - <div class="gl-w-full gl-bg-white gl-p-3" :class="cardSpacingClass"> - <div class="gl-display-flex gl-pr-3"> - <ci-status - v-if="!pipelineIsLoading" - :status="pipelineStatus" - :size="24" - css-classes="gl-top-0 gl-pr-2" - /> + <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-status v-if="!pipelineIsLoading" :status="pipelineStatus" :size="24" css-classes="" /> <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> - <div class="gl-display-flex gl-flex-direction-column gl-downstream-pipeline-job-width"> + <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"> {{ downstreamTitle }} </span> <div class="gl-text-truncate"> - <gl-link class="gl-text-blue-500!" :href="pipeline.path" data-testid="pipelineLink" + <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"> @@ -166,8 +273,8 @@ export default { <div class="gl-display-flex"> <gl-button :id="buttonId" - class="gl-shadow-none! gl-rounded-0!" - :class="`js-pipeline-expand-${pipeline.id} ${buttonBorderClass}`" + class="gl-border! gl-shadow-none! gl-rounded-lg!" + :class="[`js-pipeline-expand-${pipeline.id}`, buttonBorderClasses]" :icon="expandedIcon" :aria-label="__('Expand pipeline')" data-testid="expand-pipeline-button" diff --git a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue index 3c1208afbf0..b06c2f15042 100644 --- a/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue +++ b/app/assets/javascripts/pipelines/components/graph/linked_pipelines_column.vue @@ -66,14 +66,13 @@ export default { columnClass() { const positionValues = { right: 'gl-ml-6', - left: 'gl-mr-6', + left: 'gl-mx-6', }; + return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; }, computedTitleClasses() { - const positionalClasses = this.isUpstream - ? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding'] - : []; + const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-linked-pipeline-padding'] : []; return [...this.$options.titleClasses, ...positionalClasses]; }, @@ -202,7 +201,7 @@ export default { <li v-for="pipeline in linkedPipelines" :key="pipeline.id" - class="gl-display-flex gl-mb-4" + class="gl-display-flex gl-mb-3" :class="{ 'gl-flex-direction-row-reverse': isUpstream }" > <linked-pipeline @@ -215,6 +214,7 @@ export default { @downstreamHovered="onDownstreamHovered" @pipelineClicked="onPipelineClick(pipeline)" @pipelineExpandToggle="onPipelineExpandToggle" + @refreshPipelineGraph="$emit('refreshPipelineGraph')" /> <div v-if="showContainer(pipeline.id)" diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 04b78b8aa23..37878f3fb6d 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -93,6 +93,7 @@ export default { data() { return { pipeline: null, + failureMessages: [], failureType: null, isCanceling: false, isRetrying: false, @@ -159,8 +160,9 @@ export default { }, }, methods: { - reportFailure(errorType) { + reportFailure(errorType, errorMessages = []) { this.failureType = errorType; + this.failureMessages = errorMessages; }, async postPipelineAction(name, mutation) { try { @@ -176,7 +178,7 @@ export default { if (errors.length > 0) { this.isRetrying = false; - this.reportFailure(POST_FAILURE); + this.reportFailure(POST_FAILURE, errors); } else { await this.$apollo.queries.pipeline.refetch(); if (!this.isFinished) { @@ -214,7 +216,7 @@ export default { }); if (errors.length > 0) { - this.reportFailure(DELETE_FAILURE); + this.reportFailure(DELETE_FAILURE, errors); this.isDeleting = false; } else { redirectTo(setUrlFragment(this.paths.pipelinesPath, 'delete_success')); @@ -231,9 +233,11 @@ export default { </script> <template> <div class="js-pipeline-header-container"> - <gl-alert v-if="hasError" :variant="failure.variant" :dismissible="false">{{ - failure.text - }}</gl-alert> + <gl-alert v-if="hasError" :title="failure.text" :variant="failure.variant" :dismissible="false"> + <div v-for="(failureMessage, index) in failureMessages" :key="`failure-message-${index}`"> + {{ failureMessage }} + </div> + </gl-alert> <ci-header v-if="shouldRenderContent" :status="pipeline.detailedStatus" @@ -261,6 +265,7 @@ export default { v-if="canCancelPipeline" :loading="isCanceling" :disabled="isCanceling" + class="gl-ml-3" variant="danger" data-testid="cancelPipeline" @click="cancelPipeline()" diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue new file mode 100644 index 00000000000..9e886fd7a48 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_app.vue @@ -0,0 +1,73 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import createFlash from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import GetFailedJobsQuery from '../../graphql/queries/get_failed_jobs.query.graphql'; +import { prepareFailedJobs } from './utils'; +import FailedJobsTable from './failed_jobs_table.vue'; + +export default { + components: { + GlLoadingIcon, + FailedJobsTable, + }, + inject: { + fullPath: { + default: '', + }, + pipelineIid: { + default: '', + }, + }, + props: { + failedJobsSummary: { + type: Array, + required: true, + }, + }, + apollo: { + failedJobs: { + query: GetFailedJobsQuery, + variables() { + return { + fullPath: this.fullPath, + pipelineIid: this.pipelineIid, + }; + }, + update({ project }) { + if (project?.pipeline?.jobs?.nodes) { + return project.pipeline.jobs.nodes.map((job) => { + return { normalizedId: getIdFromGraphQLId(job.id), ...job }; + }); + } + return []; + }, + result() { + this.preparedFailedJobs = prepareFailedJobs(this.failedJobs, this.failedJobsSummary); + }, + error() { + createFlash({ message: s__('Jobs|There was a problem fetching the failed jobs.') }); + }, + }, + }, + data() { + return { + failedJobs: [], + preparedFailedJobs: [], + }; + }, + 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="preparedFailedJobs" /> + </div> +</template> diff --git a/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue new file mode 100644 index 00000000000..1c646bdf3d6 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/failed_jobs_table.vue @@ -0,0 +1,111 @@ +<script> +import { GlButton, GlLink, GlSafeHtmlDirective, GlTableLite } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import createFlash from '~/flash'; +import { redirectTo } from '~/lib/utils/url_utility'; +import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; +import RetryFailedJobMutation from '../../graphql/mutations/retry_failed_job.mutation.graphql'; +import { DEFAULT_FIELDS } from '../../constants'; + +export default { + fields: DEFAULT_FIELDS, + retry: __('Retry'), + components: { + CiBadge, + GlButton, + GlLink, + GlTableLite, + }, + directives: { + SafeHtml: GlSafeHtmlDirective, + }, + props: { + failedJobs: { + type: Array, + required: true, + }, + }, + methods: { + async retryJob(id) { + 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); + } + } catch { + this.showErrorMessage(); + } + }, + canRetryJob(job) { + return job.retryable && job.userPermissions.updateBuild; + }, + showErrorMessage() { + createFlash({ message: s__('Job|There was a problem retrying the failed job.') }); + }, + }, +}; +</script> + +<template> + <gl-table-lite :items="failedJobs" :fields="$options.fields" stacked="lg" fixed> + <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 :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(failure)="{ item }"> + <span>{{ item.failure }}</span> + </template> + + <template #cell(actions)="{ item }"> + <gl-button + v-if="canRetryJob(item)" + icon="repeat" + :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="item.failureSummary" class="gl-reset-bg gl-p-0" > + </code> + </pre> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/pipelines/components/jobs/utils.js b/app/assets/javascripts/pipelines/components/jobs/utils.js new file mode 100644 index 00000000000..c8414d44d14 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/jobs/utils.js @@ -0,0 +1,33 @@ +/* + We get the failure and failure summary from Rails which has + a summary failure log. Here we combine that data with the data + from GraphQL to display the log. + + failedJobs is from GraphQL + failedJobsSummary is from Rails + */ + +export const prepareFailedJobs = (failedJobs = [], failedJobsSummary = []) => { + const combinedJobs = []; + + if (failedJobs.length > 0 && failedJobsSummary.length > 0) { + failedJobs.forEach((failedJob) => { + const foundJob = failedJobsSummary.find( + (failedJobSummary) => failedJob.normalizedId === failedJobSummary.id, + ); + + if (foundJob) { + combinedJobs.push({ + ...failedJob, + failure: foundJob?.failure, + failureSummary: foundJob?.failure_summary, + // this field is needed for the slot row-details + // on the failed_jobs_table.vue component + _showDetails: true, + }); + } + }); + } + + return combinedJobs; +}; diff --git a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue index 62c785d7ad2..66d30c10362 100644 --- a/app/assets/javascripts/pipelines/components/pipeline_tabs.vue +++ b/app/assets/javascripts/pipelines/components/pipeline_tabs.vue @@ -1,6 +1,7 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; import { __ } from '~/locale'; +import { failedJobsTabName, jobsTabName, needsTabName, testReportTabName } from '../constants'; import PipelineGraphWrapper from './graph/graph_component_wrapper.vue'; import Dag from './dag/dag.vue'; import JobsApp from './jobs/jobs_app.vue'; @@ -16,6 +17,12 @@ export default { testsTitle: __('Tests'), }, }, + tabNames: { + needs: needsTabName, + jobs: jobsTabName, + failures: failedJobsTabName, + tests: testReportTabName, + }, components: { Dag, GlTab, @@ -25,24 +32,47 @@ export default { PipelineGraphWrapper, TestReports, }, + inject: ['defaultTabValue'], + methods: { + isActive(tabName) { + return tabName === this.defaultTabValue; + }, + }, }; </script> <template> <gl-tabs> - <gl-tab :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> + <gl-tab ref="pipelineTab" :title="$options.i18n.tabs.pipelineTitle" data-testid="pipeline-tab"> <pipeline-graph-wrapper /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.needsTitle" data-testid="dag-tab"> + <gl-tab + ref="dagTab" + :title="$options.i18n.tabs.needsTitle" + :active="isActive($options.tabNames.needs)" + data-testid="dag-tab" + > <dag /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.jobsTitle" data-testid="jobs-tab"> + <gl-tab + :title="$options.i18n.tabs.jobsTitle" + :active="isActive($options.tabNames.jobs)" + data-testid="jobs-tab" + > <jobs-app /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.failedJobsTitle" data-testid="failed-jobs-tab"> + <gl-tab + :title="$options.i18n.tabs.failedJobsTitle" + :active="isActive($options.tabNames.failures)" + data-testid="failed-jobs-tab" + > <failed-jobs-app /> </gl-tab> - <gl-tab :title="$options.i18n.tabs.testsTitle" data-testid="tests-tab"> + <gl-tab + :title="$options.i18n.tabs.testsTitle" + :active="isActive($options.tabNames.tests)" + data-testid="tests-tab" + > <test-reports /> </gl-tab> <slot></slot> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue index 5a9c85a0f10..3bbdfc73e1b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state.vue @@ -1,7 +1,9 @@ <script> import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; +import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import PipelinesCiTemplates from './empty_state/pipelines_ci_templates.vue'; +import IosTemplates from './empty_state/ios_templates.vue'; export default { i18n: { @@ -10,7 +12,9 @@ export default { name: 'PipelinesEmptyState', components: { GlEmptyState, + GitlabExperiment, PipelinesCiTemplates, + IosTemplates, }, props: { emptyStateSvgPath: { @@ -21,26 +25,24 @@ export default { type: Boolean, required: true, }, - ciRunnerSettingsPath: { + registrationToken: { type: String, required: false, default: null, }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, }, }; </script> <template> <div> - <pipelines-ci-templates - v-if="canSetCi" - :ci-runner-settings-path="ciRunnerSettingsPath" - :any-runners-available="anyRunnersAvailable" - /> + <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="" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue index 3b312e78d11..64d4414eb94 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ci_templates.vue @@ -12,15 +12,31 @@ export default { }, 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.map(({ name, logo }) => { - return { - name, - logo, - link: mergeUrlParams({ template: name }, this.pipelineEditorPath), - description: sprintf(this.$options.i18n.description, { name }), - }; - }); + const templates = this.suggestedCiTemplates + .filter( + (template) => !this.filterTemplates.length || this.filterTemplates.includes(template.name), + ) + .map(({ name, logo, title }) => { + return { + name: title || name, + logo, + link: mergeUrlParams({ template: name }, this.pipelineEditorPath), + description: sprintf(this.$options.i18n.description, { name: title || name }), + }; + }); return { templates, @@ -34,7 +50,9 @@ export default { }, }, i18n: { - description: s__('Pipelines|CI/CD template to test and deploy your %{name} project.'), + description: s__( + 'Pipelines|Continuous integration and deployment template to test and deploy your %{name} project.', + ), cta: s__('Pipelines|Use template'), }, AVATAR_SHAPE_OPTION_RECT, @@ -67,6 +85,7 @@ export default { </div> </div> <gl-button + :disabled="disabled" category="primary" variant="confirm" :href="template.link" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/ios_templates.vue new file mode 100644 index 00000000000..8ff311e90e7 --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/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 } from '~/lib/utils/url_utility'; +import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; +import apolloProvider from '~/pipelines/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: 'https://docs.gitlab.com/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/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue index be46a7f5cec..3eafb36bd1d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates.vue @@ -33,19 +33,7 @@ export default { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, - inject: ['pipelineEditorPath'], - props: { - ciRunnerSettingsPath: { - type: String, - required: false, - default: null, - }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, - }, + inject: ['anyRunnersAvailable', 'pipelineEditorPath', 'ciRunnerSettingsPath'], data() { return { gettingStartedTemplateUrl: mergeUrlParams( diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue index 2b33467e948..e35fccf2d7e 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_mini_graph.vue @@ -36,7 +36,7 @@ export default { }; </script> <template> - <div data-testid="pipeline-mini-graph"> + <div data-testid="pipeline-mini-graph" class="gl-display-inline gl-vertical-align-middle gl-my-1"> <div v-for="stage in stages" :key="stage.name" diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue index afcb04cd7eb..53e21d4ce8b 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipeline_stage.vue @@ -12,7 +12,8 @@ * 4. Commit widget */ -import { GlDropdown, GlLoadingIcon, GlTooltipDirective, GlIcon } from '@gitlab/ui'; +import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { __, sprintf } from '~/locale'; @@ -21,7 +22,7 @@ import JobItem from './job_item.vue'; export default { components: { - GlIcon, + CiIcon, GlLoadingIcon, GlDropdown, JobItem, @@ -51,14 +52,6 @@ export default { dropdownContent: [], }; }, - computed: { - triggerButtonClass() { - return `ci-status-icon-${this.stage.status.group}`; - }, - borderlessIcon() { - return `${this.stage.status.icon}_borderless`; - }, - }, watch: { updateDropdown() { if (this.updateDropdown && this.isDropdownOpen() && !this.isLoading) { @@ -114,15 +107,21 @@ export default { variant="link" :aria-label="stageAriaLabel(stage.title)" :lazy="true" - :popper-opts="{ placement: 'bottom' }" - :toggle-class="['mini-pipeline-graph-dropdown-toggle', triggerButtonClass]" + :popper-opts="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ { + placement: 'bottom', + } /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" + :toggle-class="['gl-rounded-full!']" menu-class="mini-pipeline-graph-dropdown-menu" @show="onShowDropdown" > <template #button-content> - <span class="gl-pointer-events-none"> - <gl-icon :name="borderlessIcon" /> - </span> + <ci-icon + is-interactive + css-classes="gl-rounded-full" + :size="24" + :status="stage.status" + class="gl-align-items-center gl-display-inline-flex" + /> </template> <gl-loading-icon v-if="isLoading" size="sm" /> <ul diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue index db9dc74863d..485e338f639 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines.vue @@ -107,16 +107,11 @@ export default { type: Object, required: true, }, - ciRunnerSettingsPath: { + registrationToken: { type: String, required: false, default: null, }, - anyRunnersAvailable: { - type: Boolean, - required: false, - default: true, - }, }, data() { return { @@ -386,8 +381,7 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="emptyStateSvgPath" :can-set-ci="canCreatePipeline" - :ci-runner-settings-path="ciRunnerSettingsPath" - :any-runners-available="anyRunnersAvailable" + :registration-token="registrationToken" /> <gl-empty-state diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue index 77b9c2b5203..53da98434b0 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/pipelines_table.vue @@ -174,12 +174,13 @@ export default { <div></div> <linked-pipelines-mini-list v-if="item.triggered_by" - :triggered-by="[item.triggered_by]" + :triggered-by="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ + item.triggered_by, + ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" data-testid="mini-graph-upstream" /> <pipeline-mini-graph v-if="item.details && item.details.stages && item.details.stages.length > 0" - class="gl-display-inline" :stages="item.details.stages" :update-dropdown="updateGraphDropdown" @pipelineActionRequestComplete="onPipelineActionRequestComplete" diff --git a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue index 51373e712ff..9b0e6560c53 100644 --- a/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue +++ b/app/assets/javascripts/pipelines/components/test_reports/test_suite_table.vue @@ -35,7 +35,7 @@ export default { }, computed: { ...mapState(['pageInfo']), - ...mapGetters(['getSuiteTests', 'getSuiteTestCount']), + ...mapGetters(['getSuiteTests', 'getSuiteTestCount', 'getSuiteArtifactsExpired']), hasSuites() { return this.getSuiteTests.length > 0; }, @@ -80,7 +80,8 @@ export default { <div v-for="(testCase, index) in getSuiteTests" :key="index" - class="gl-responsive-table-row rounded align-items-md-start mt-xs-3 js-case-row" + class="gl-responsive-table-row rounded align-items-md-start" + data-testid="test-case-row" > <div class="table-section section-20 section-wrap"> <div role="rowheader" class="table-mobile-header">{{ __('Suite') }}</div> @@ -157,7 +158,16 @@ export default { </div> <div v-else> - <p class="js-no-test-cases">{{ s__('TestReports|There are no test cases to display.') }}</p> + <p data-testid="no-test-cases"> + {{ s__('TestReports|There are no test cases to display.') }} + </p> + <p v-if="getSuiteArtifactsExpired" data-testid="artifacts-expired"> + {{ + s__( + 'TestReports|Test details are populated by job artifacts. The job artifacts from this pipeline are expired.', + ) + }} + </p> </div> </div> </template> |