diff options
Diffstat (limited to 'app/assets/javascripts/pipelines/components/pipelines_list/failure_widget')
-rw-r--r-- | app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue (renamed from app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue) | 71 | ||||
-rw-r--r-- | app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue | 166 | ||||
-rw-r--r-- | app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue | 108 | ||||
-rw-r--r-- | app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js | 4 |
4 files changed, 270 insertions, 79 deletions
diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue index e40e30f2b8d..6b5e3d77b92 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/widget_failed_job_row.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_job_details.vue @@ -1,16 +1,20 @@ <script> -import { GlCollapse, GlIcon, GlLink } from '@gitlab/ui'; -import { s__, sprintf } from '~/locale'; +import { GlButton, GlCollapse, 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 RetryMrFailedJobMutation from '../../../graphql/mutations/retry_mr_failed_job.mutation.graphql'; export default { components: { CiIcon, + GlButton, GlCollapse, GlIcon, GlLink, + GlTooltip, }, directives: { SafeHtml, @@ -23,14 +27,21 @@ export default { }, data() { return { - isJobLogVisible: false, isHovered: false, + isJobLogVisible: false, + isLoadingAction: false, }; }, computed: { activeClass() { return this.isHovered ? 'gl-bg-gray-50' : ''; }, + canReadBuild() { + return this.job.userPermissions.readBuild; + }, + canRetryJob() { + return this.job.retryable && this.job.userPermissions.updateBuild; + }, isVisibleId() { return `log-${this.isJobLogVisible ? 'is-visible' : 'is-hidden'}`; }, @@ -38,7 +49,11 @@ export default { return this.isJobLogVisible ? 'chevron-down' : 'chevron-right'; }, jobTrace() { - return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + if (this.canReadBuild) { + return this.job?.trace?.htmlSummary || this.$options.i18n.noTraceText; + } + + return this.$options.i18n.cannotReadBuild; }, parsedJobId() { return getIdFromGraphQLId(this.job.id); @@ -54,18 +69,45 @@ export default { resetActiveRow() { this.isHovered = false; }, - toggleJobLog(e) { + 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 (e.target.tagName === 'A') { + 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 retry this job'), jobActionTooltipText: s__('Pipelines|Retry %{jobName} Job'), noTraceText: s__('Job|No job log'), + retry: __('Retry'), + retryError: __('There was an error while retrying this job'), }, }; </script> @@ -93,6 +135,21 @@ export default { <div class="col-2 gl-text-left"> <gl-link :href="job.webPath">#{{ parsedJobId }}</gl-link> </div> + <gl-tooltip v-if="!canRetryJob" :target="() => $refs.retryBtn" placement="top"> + {{ $options.i18n.cannotRetry }} + </gl-tooltip> + <div class="col-2 gl-text-left"> + <span ref="retryBtn"> + <gl-button + :disabled="!canRetryJob" + icon="retry" + :loading="isLoadingAction" + :title="$options.i18n.retry" + :aria-label="$options.i18n.retry" + @click.stop="retryJob" + /> + </span> + </div> </div> <div class="row"> <gl-collapse :visible="isJobLogVisible" class="gl-w-full"> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue new file mode 100644 index 00000000000..36687129cdd --- /dev/null +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/failed_jobs_list.vue @@ -0,0 +1,166 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import { createAlert } from '~/alert'; +import { __, s__, sprintf } from '~/locale'; +import { getQueryHeaders } from '~/pipelines/components/graph/utils'; +import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; +import { graphqlEtagPipelinePath, sortJobsByStatus } from './utils'; +import FailedJobDetails from './failed_job_details.vue'; + +const POLL_INTERVAL = 10000; + +const JOB_ACTION_HEADER = __('Actions'); +const JOB_ID_HEADER = __('Job ID'); +const JOB_NAME_HEADER = __('Job name'); +const STAGE_HEADER = __('Stage'); + +export default { + components: { + GlLoadingIcon, + FailedJobDetails, + }, + inject: ['fullPath', 'graphqlPath'], + props: { + isPipelineActive: { + required: true, + type: Boolean, + }, + pipelineIid: { + type: Number, + 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.fullPath, + 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); + }, + }, + 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' }, + { text: JOB_ACTION_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" /> + <div v-else-if="!hasFailedJobs">{{ $options.i18n.noFailedJobs }}</div> + <div v-else class="container-fluid gl-grid-tpl-rows-auto"> + <div class="row gl-mb-6 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/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue index fce0b5f525e..5e49c05f47d 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/pipeline_failed_jobs_widget.vue @@ -1,22 +1,7 @@ <script> -import { - GlButton, - GlCollapse, - GlIcon, - GlLink, - GlLoadingIcon, - GlPopover, - GlSprintf, -} from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { __, s__ } from '~/locale'; -import getPipelineFailedJobs from '../../../graphql/queries/get_pipeline_failed_jobs.query.graphql'; -import WidgetFailedJobRow from './widget_failed_job_row.vue'; -import { sortJobsByStatus } from './utils'; - -const JOB_ID_HEADER = __('Job ID'); -const JOB_NAME_HEADER = __('Job name'); -const STAGE_HEADER = __('Stage'); +import { GlButton, GlCollapse, GlIcon, GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; +import { __, s__, sprintf } from '~/locale'; +import FailedJobsList from './failed_jobs_list.vue'; export default { components: { @@ -24,13 +9,20 @@ export default { GlCollapse, GlIcon, GlLink, - GlLoadingIcon, GlPopover, GlSprintf, - WidgetFailedJobRow, + FailedJobsList, }, inject: ['fullPath'], props: { + failedJobsCount: { + required: true, + type: Number, + }, + isPipelineActive: { + required: true, + type: Boolean, + }, pipelineIid: { required: true, type: Number, @@ -42,62 +34,44 @@ export default { }, data() { return { - failedJobs: [], + currentFailedJobsCount: this.failedJobsCount, + isActive: false, isExpanded: false, }; }, - apollo: { - failedJobs: { - query: getPipelineFailedJobs, - skip() { - return !this.isExpanded; - }, - variables() { - return { - fullPath: this.fullPath, - pipelineIid: this.pipelineIid, - }; - }, - update(data) { - const jobs = data?.project?.pipeline?.jobs?.nodes || []; - return sortJobsByStatus(jobs); - }, - error(e) { - createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); - }, - }, - }, computed: { bodyClasses() { return this.isExpanded ? '' : 'gl-display-none'; }, - failedJobsCount() { - return this.failedJobs.length; + failedJobsCountText() { + return sprintf(this.$options.i18n.showFailedJobs, { count: this.currentFailedJobsCount }); }, iconName() { return this.isExpanded ? 'chevron-down' : 'chevron-right'; }, - isLoading() { - return this.$apollo.queries.failedJobs.loading; + popoverId() { + return `popover-${this.pipelineIid}`; + }, + }, + watch: { + failedJobsCount(val) { + this.currentFailedJobsCount = val; }, }, methods: { + setFailedJobsCount(count) { + this.currentFailedJobsCount = count; + }, toggleWidget() { this.isExpanded = !this.isExpanded; }, }, - columns: [ - { text: JOB_NAME_HEADER, class: 'col-6' }, - { text: STAGE_HEADER, class: 'col-2' }, - { text: JOB_ID_HEADER, class: 'col-2' }, - ], 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'), - fetchError: __('There was a problem fetching failed jobs'), - showFailedJobs: __('Show failed jobs'), + showFailedJobs: __('Show failed jobs (%{count})'), }, }; </script> @@ -105,9 +79,9 @@ export default { <div class="gl-border-none!"> <gl-button variant="link" @click="toggleWidget"> <gl-icon :name="iconName" /> - {{ $options.i18n.showFailedJobs }} - <gl-icon id="target" name="information-o" /> - <gl-popover target="target" placement="top"> + {{ failedJobsCountText }} + <gl-icon :id="popoverId" name="information-o" /> + <gl-popover :target="popoverId" placement="top"> <template #title> {{ $options.i18n.additionalInfoTitle }} </template> <slot> <gl-sprintf :message="$options.i18n.additionalInfoPopover"> @@ -118,26 +92,16 @@ export default { </slot> </gl-popover> </gl-button> - <gl-loading-icon v-if="isLoading" /> <gl-collapse - v-else v-model="isExpanded" class="gl-bg-gray-10 gl-border-1 gl-border-t gl-border-color-gray-100 gl-mt-4 gl-pt-3" > - <div class="container-fluid gl-grid-tpl-rows-auto"> - <div class="row gl-mb-6 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> - <widget-failed-job-row v-for="job in failedJobs" :key="job.id" :job="job" /> + <failed-jobs-list + v-if="isExpanded" + :is-pipeline-active="isPipelineActive" + :pipeline-iid="pipelineIid" + @failed-jobs-count="setFailedJobsCount" + /> </gl-collapse> </div> </template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js index 3f395fff7e0..2d0c467c54f 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js +++ b/app/assets/javascripts/pipelines/components/pipelines_list/failure_widget/utils.js @@ -13,3 +13,7 @@ export const sortJobsByStatus = (jobs = []) => { return 1; }); }; + +export const graphqlEtagPipelinePath = (graphqlPath, pipelineId) => { + return `${graphqlPath}pipelines/id/${pipelineId}`; +}; |