diff options
Diffstat (limited to 'app/assets/javascripts/jobs/components/job/sidebar')
13 files changed, 1141 insertions, 0 deletions
diff --git a/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue new file mode 100644 index 00000000000..2018942a7e8 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/artifacts_block.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlButtonGroup, GlIcon, GlLink } from '@gitlab/ui'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; + +export default { + components: { + GlButton, + GlButtonGroup, + GlIcon, + GlLink, + 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">{{ s__('Job|Job artifacts') }}</div> + <p + v-if="isExpired || willExpire" + class="build-detail-row" + data-testid="artifacts-remove-timeline" + > + <span v-if="isExpired">{{ s__('Job|The artifacts were removed') }}</span> + <span v-if="willExpire">{{ s__('Job|The artifacts will be removed') }}</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" /> + </gl-link> + </p> + <p v-else-if="isLocked" class="build-detail-row"> + <span data-testid="job-locked-message">{{ + s__( + 'Job|These artifacts are the latest. They will not be deleted (even if expired) until newer artifacts are available.', + ) + }}</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" + >{{ s__('Job|Keep') }}</gl-button + > + <gl-button + v-if="artifact.download_path" + :href="artifact.download_path" + rel="nofollow" + data-testid="download-artifacts" + download + >{{ s__('Job|Download') }}</gl-button + > + <gl-button + v-if="artifact.browse_path" + :href="artifact.browse_path" + data-testid="browse-artifacts" + data-qa-selector="browse_artifacts_button" + >{{ s__('Job|Browse') }}</gl-button + > + </gl-button-group> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue new file mode 100644 index 00000000000..7f25ca8a94d --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/commit_block.vue @@ -0,0 +1,47 @@ +<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> + <span class="gl-font-weight-bold">{{ __('Commit') }}</span> + + <gl-link :href="commit.commit_path" class="gl-text-blue-600!" data-testid="commit-sha"> + {{ commit.short_id }} + </gl-link> + + <clipboard-button + :text="commit.id" + :title="__('Copy commit SHA')" + category="tertiary" + size="small" + /> + + <span v-if="mergeRequest"> + {{ __('in') }} + <gl-link :href="mergeRequest.path" class="gl-text-blue-600!" data-testid="link-commit" + >!{{ mergeRequest.iid }}</gl-link + > + </span> + + <p class="gl-mb-0">{{ commit.title }}</p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue new file mode 100644 index 00000000000..097ab3b4cf6 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_container_item.vue @@ -0,0 +1,77 @@ +<script> +import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import delayedJobMixin from '~/jobs/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: 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" + :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-2" :size="14" /> + + <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> + + <gl-icon v-if="job.retried" name="retry" /> + </gl-link> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue new file mode 100644 index 00000000000..913924cc7b1 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_retry_forward_deployment_modal.vue @@ -0,0 +1,66 @@ +<script> +import { GlLink, GlModal } from '@gitlab/ui'; +import { JOB_RETRY_FORWARD_DEPLOYMENT_MODAL } from '~/jobs/constants'; + +export default { + name: 'JobRetryForwardDeploymentModal', + components: { + GlLink, + GlModal, + }, + i18n: { + ...JOB_RETRY_FORWARD_DEPLOYMENT_MODAL, + }, + 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/jobs/components/job/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue new file mode 100644 index 00000000000..dd620977f0c --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/job_sidebar_retry_button.vue @@ -0,0 +1,53 @@ +<script> +import { GlButton, GlModalDirective } from '@gitlab/ui'; +import { mapGetters } from 'vuex'; +import { JOB_SIDEBAR_COPY } from '~/jobs/constants'; + +export default { + name: 'JobSidebarRetryButton', + i18n: { + retryLabel: JOB_SIDEBAR_COPY.retry, + }, + components: { + GlButton, + }, + directives: { + GlModal: GlModalDirective, + }, + props: { + modalId: { + type: String, + required: true, + }, + href: { + type: String, + required: true, + }, + }, + computed: { + ...mapGetters(['hasForwardDeploymentFailure']), + }, +}; +</script> +<template> + <gl-button + v-if="hasForwardDeploymentFailure" + v-gl-modal="modalId" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="confirm" + icon="retry" + data-testid="retry-job-button" + /> + + <gl-button + v-else + :href="href" + :aria-label="$options.i18n.retryLabel" + category="primary" + variant="confirm" + icon="retry" + data-method="post" + data-testid="retry-job-link" + /> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue new file mode 100644 index 00000000000..df64b6422c7 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/jobs_container.vue @@ -0,0 +1,35 @@ +<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="builds-container"> + <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/jobs/components/job/sidebar/legacy_sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue new file mode 100644 index 00000000000..263b2d141c9 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/legacy_sidebar_header.vue @@ -0,0 +1,99 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +export default { + name: 'LegacySidebarHeader', + i18n: { + ...JOB_SIDEBAR_COPY, + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + props: { + job: { + type: Object, + required: true, + default: () => ({}), + }, + erasePath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + retryButtonCategory() { + return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> + {{ job.name }} + </h4> + </tooltip-on-truncate> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + v-if="erasePath" + v-gl-tooltip.left + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="erasePath" + :data-confirm="$options.i18n.eraseLogConfirmText" + class="gl-mr-2" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <job-sidebar-retry-button + v-if="job.retry_path" + v-gl-tooltip.left + :title="$options.i18n.retryJobButtonLabel" + :aria-label="$options.i18n.retryJobButtonLabel" + :category="retryButtonCategory" + :href="job.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-qa-selector="retry_button" + data-testid="retry-button" + /> + <gl-button + v-if="job.cancel_path" + v-gl-tooltip.left + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="job.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue new file mode 100644 index 00000000000..b0db48df01f --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar.vue @@ -0,0 +1,149 @@ +<script> +import { GlButton, GlIcon } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import CommitBlock from './commit_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 ArtifactsBlock from './artifacts_block.vue'; +import LegacySidebarHeader from './legacy_sidebar_header.vue'; +import SidebarHeader from './sidebar_header.vue'; +import StagesDropdown from './stages_dropdown.vue'; +import TriggerBlock from './trigger_block.vue'; + +export default { + name: 'JobSidebar', + i18n: { + ...JOB_SIDEBAR_COPY, + }, + borderTopClass: ['gl-border-t-solid', 'gl-border-t-1', 'gl-border-t-gray-100'], + forwardDeploymentFailureModalId, + components: { + ArtifactsBlock, + CommitBlock, + GlButton, + GlIcon, + JobsContainer, + JobRetryForwardDeploymentModal, + JobSidebarDetailsContainer, + LegacySidebarHeader, + SidebarHeader, + StagesDropdown, + TriggerBlock, + }, + mixins: [glFeatureFlagsMixin()], + props: { + artifactHelpUrl: { + type: String, + required: false, + default: '', + }, + erasePath: { + type: String, + required: false, + default: null, + }, + }, + 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; + }, + hasTriggers() { + return !isEmpty(this.job.trigger); + }, + isGraphQL() { + return this.glFeatures?.graphqlJobApp; + }, + commit() { + return this.job?.pipeline?.commit || {}; + }, + shouldShowJobRetryForwardDeploymentModal() { + return this.job.retry_path && this.hasForwardDeploymentFailure; + }, + }, + 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"> + <sidebar-header v-if="isGraphQL" :erase-path="erasePath" :job="job" /> + <legacy-sidebar-header v-else :erase-path="erasePath" :job="job" /> + <div + v-if="job.terminal_path || job.new_issue_path" + class="gl-py-5" + :class="$options.borderTopClass" + > + <gl-button + v-if="job.new_issue_path" + :href="job.new_issue_path" + category="secondary" + variant="confirm" + data-testid="job-new-issue" + > + {{ $options.i18n.newIssue }} + </gl-button> + <gl-button + v-if="job.terminal_path" + :href="job.terminal_path" + target="_blank" + data-testid="terminal-link" + > + {{ $options.i18n.debug }} + <gl-icon name="external-link" /> + </gl-button> + </div> + + <job-sidebar-details-container class="gl-py-5" :class="$options.borderTopClass" /> + + <artifacts-block + v-if="hasArtifact" + class="gl-py-5" + :class="$options.borderTopClass" + :artifact="job.artifact" + :help-url="artifactHelpUrl" + /> + + <trigger-block + v-if="hasTriggers" + class="gl-py-5" + :class="$options.borderTopClass" + :trigger="job.trigger" + /> + + <commit-block + :commit="commit" + class="gl-py-5" + :class="$options.borderTopClass" + :merge-request="job.merge_request" + /> + + <stages-dropdown + v-if="job.pipeline" + class="gl-py-5" + :class="$options.borderTopClass" + :pipeline="job.pipeline" + :selected-stage="selectedStage" + :stages="stages" + @requestSidebarStageDropdown="fetchJobsForStage" + /> + </div> + + <jobs-container v-if="jobs.length" :job-id="job.id" :jobs="jobs" /> + </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/jobs/components/job/sidebar/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue new file mode 100644 index 00000000000..05567328660 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_detail_row.vue @@ -0,0 +1,45 @@ +<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: '', + }, + }, + computed: { + hasTitle() { + return this.title.length > 0; + }, + hasHelpURL() { + return this.helpUrl.length > 0; + }, + }, +}; +</script> +<template> + <p class="gl-display-flex gl-justify-content-space-between gl-mb-2"> + <span v-if="hasTitle" + ><b>{{ title }}:</b> {{ value }}</span + > + <gl-link v-if="hasHelpURL" :href="helpUrl" target="_blank"> + <gl-icon name="question-o" /> + </gl-link> + </p> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue new file mode 100644 index 00000000000..523710598bf --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_header.vue @@ -0,0 +1,102 @@ +<script> +import { GlButton, GlTooltipDirective } from '@gitlab/ui'; +import { mapActions } from 'vuex'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; +import { JOB_SIDEBAR_COPY, forwardDeploymentFailureModalId } from '~/jobs/constants'; +import JobSidebarRetryButton from './job_sidebar_retry_button.vue'; + +// This component is a port of ~/jobs/components/job/sidebar/legacy_sidebar_header.vue +// It is meant to fetch the job information via GraphQL instead of REST API. + +export default { + name: 'SidebarHeader', + i18n: { + ...JOB_SIDEBAR_COPY, + }, + forwardDeploymentFailureModalId, + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlButton, + JobSidebarRetryButton, + TooltipOnTruncate, + }, + props: { + job: { + type: Object, + required: true, + default: () => ({}), + }, + erasePath: { + type: String, + required: false, + default: null, + }, + }, + computed: { + retryButtonCategory() { + return this.job.status && this.job.recoverable ? 'primary' : 'secondary'; + }, + }, + methods: { + ...mapActions(['toggleSidebar']), + }, +}; +</script> + +<template> + <div class="gl-py-5 gl-display-flex gl-align-items-center"> + <tooltip-on-truncate :title="job.name" truncate-target="child" + ><h4 class="gl-my-0 gl-mr-3 gl-text-truncate"> + {{ job.name }} + </h4> + </tooltip-on-truncate> + <div class="gl-flex-grow-1 gl-flex-shrink-0 gl-text-right"> + <gl-button + v-if="erasePath" + v-gl-tooltip.left + :title="$options.i18n.eraseLogButtonLabel" + :aria-label="$options.i18n.eraseLogButtonLabel" + :href="erasePath" + :data-confirm="$options.i18n.eraseLogConfirmText" + class="gl-mr-2" + data-testid="job-log-erase-link" + data-confirm-btn-variant="danger" + data-method="post" + icon="remove" + /> + <job-sidebar-retry-button + v-if="job.retry_path" + v-gl-tooltip.left + :title="$options.i18n.retryJobButtonLabel" + :aria-label="$options.i18n.retryJobButtonLabel" + :category="retryButtonCategory" + :href="job.retry_path" + :modal-id="$options.forwardDeploymentFailureModalId" + variant="confirm" + data-qa-selector="retry_button" + data-testid="retry-button" + /> + <gl-button + v-if="job.cancel_path" + v-gl-tooltip.left + :title="$options.i18n.cancelJobButtonLabel" + :aria-label="$options.i18n.cancelJobButtonLabel" + :href="job.cancel_path" + variant="danger" + icon="cancel" + data-method="post" + data-testid="cancel-button" + rel="nofollow" + /> + <gl-button + :aria-label="$options.i18n.toggleSidebar" + category="tertiary" + class="gl-md-display-none gl-ml-2" + icon="chevron-double-lg-right" + @click="toggleSidebar" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue new file mode 100644 index 00000000000..3b1509e5be5 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/sidebar_job_details_container.vue @@ -0,0 +1,112 @@ +<script> +import { mapState } from 'vuex'; +import { GlBadge } from '@gitlab/ui'; +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.runner || + this.job.coverage, + ); + }, + 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, + }); + }, + }, + i18n: { + COVERAGE: __('Coverage'), + FINISHED: __('Finished'), + ERASED: __('Erased'), + QUEUED: __('Queued'), + RUNNER: __('Runner'), + TAGS: __('Tags:'), + TIMEOUT: __('Timeout'), + }, + RUNNER_HELP_URL: 'https://docs.gitlab.com/runner/register/index.html', +}; +</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.RUNNER_HELP_URL" + :value="timeout" + data-testid="job-timeout" + :title="$options.i18n.TIMEOUT" + /> + <detail-row v-if="job.runner" :value="runnerId" :title="$options.i18n.RUNNER" /> + <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">{{ tag }}</gl-badge> + </p> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue new file mode 100644 index 00000000000..e3afe9b7c67 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/stages_dropdown.vue @@ -0,0 +1,167 @@ +<script> +import { GlLink, GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import Mousetrap from 'mousetrap'; +import { s__ } from '~/locale'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; +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: { + CiIcon, + ClipboardButton, + GlDropdown, + GlDropdownItem, + GlLink, + GlSprintf, + }, + props: { + pipeline: { + type: Object, + required: true, + }, + stages: { + type: Array, + required: true, + }, + selectedStage: { + type: String, + required: true, + }, + }, + computed: { + 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}'); + } else if (!this.isTriggeredByMergeRequest) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} for %{ref}'); + } else if (!this.isMergeRequestPipeline) { + return s__('Job|%{boldStart}Pipeline%{boldEnd} %{id} 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; + } else 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="js-pipeline-info" data-testid="pipeline-info"> + <ci-icon :status="pipeline.details.status" /> + <gl-sprintf :message="pipelineInfo"> + <template #bold="{ content }"> + <span class="font-weight-bold">{{ content }}</span> + </template> + <template #id> + <gl-link + :href="pipeline.path" + class="js-pipeline-path link-commit" + data-testid="pipeline-path" + data-qa-selector="pipeline_path" + >#{{ pipeline.id }}</gl-link + > + </template> + <template #mrId> + <gl-link + :href="pipeline.merge_request.path" + class="link-commit ref-name" + data-testid="mr-link" + >!{{ pipeline.merge_request.iid }}</gl-link + > + </template> + <template #ref> + <gl-link + :href="pipeline.ref.path" + class="link-commit ref-name" + 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" + 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" + 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-dropdown :text="selectedStage" class="js-selected-stage gl-w-full gl-mt-3"> + <gl-dropdown-item + v-for="stage in stages" + :key="stage.name" + class="js-stage-item stage-item" + @click="onStageClick(stage)" + > + {{ stage.name }} + </gl-dropdown-item> + </gl-dropdown> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue new file mode 100644 index 00000000000..1afc1c9a595 --- /dev/null +++ b/app/assets/javascripts/jobs/components/job/sidebar/trigger_block.vue @@ -0,0 +1,98 @@ +<script> +import { GlButton, GlTableLite } from '@gitlab/ui'; +import { __ } from '~/locale'; + +const DEFAULT_TD_CLASSES = 'gl-w-half gl-font-sm! gl-border-gray-200!'; +const DEFAULT_TH_CLASSES = + 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1!'; + +export default { + fields: [ + { + key: 'key', + label: __('Key'), + tdAttr: { 'data-testid': 'trigger-build-key' }, + tdClass: DEFAULT_TD_CLASSES, + thClass: DEFAULT_TH_CLASSES, + }, + { + key: 'value', + label: __('Value'), + tdAttr: { 'data-testid': 'trigger-build-value' }, + tdClass: DEFAULT_TD_CLASSES, + thClass: DEFAULT_TH_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-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> |