diff options
Diffstat (limited to 'app/assets/javascripts/ci/jobs_page/components')
7 files changed, 780 insertions, 0 deletions
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> |