diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-06-12 09:20:19 +0000 |
---|---|---|
committer | Phil Hughes <me@iamphill.com> | 2017-06-12 09:20:19 +0000 |
commit | 452202e36d3e20755b099a718a92d3f7b80fabb8 (patch) | |
tree | 5cc8bd9c5d810a645f44f515c4310731fa01785f /app | |
parent | d25f6fcf629bd773ccac49a799393479c48f4673 (diff) | |
download | gitlab-ce-452202e36d3e20755b099a718a92d3f7b80fabb8.tar.gz |
Improve Job detail view to make it refreshed in real-time instead of reloading
Diffstat (limited to 'app')
19 files changed, 642 insertions, 165 deletions
diff --git a/app/assets/javascripts/build.js b/app/assets/javascripts/build.js index d80b7f5bd42..c28f6e151a0 100644 --- a/app/assets/javascripts/build.js +++ b/app/assets/javascripts/build.js @@ -149,27 +149,34 @@ window.Build = (function () { Build.prototype.verifyTopPosition = function () { const $buildPage = $('.build-page'); + const $flashError = $('.alert-wrapper'); const $header = $('.build-header', $buildPage); const $runnersStuck = $('.js-build-stuck', $buildPage); const $startsEnvironment = $('.js-environment-container', $buildPage); const $erased = $('.js-build-erased', $buildPage); + const prependTopDefault = 20; + // header + navigation + margin let topPostion = 168; - if ($header) { + if ($header.length) { topPostion += $header.outerHeight(); } - if ($runnersStuck) { + if ($runnersStuck.length) { topPostion += $runnersStuck.outerHeight(); } - if ($startsEnvironment) { - topPostion += $startsEnvironment.outerHeight(); + if ($startsEnvironment.length) { + topPostion += $startsEnvironment.outerHeight() + prependTopDefault; } - if ($erased) { - topPostion += $erased.outerHeight() + 10; + if ($erased.length) { + topPostion += $erased.outerHeight() + prependTopDefault; + } + + if ($flashError.length) { + topPostion += $flashError.outerHeight(); } this.$buildTrace.css({ @@ -245,6 +252,7 @@ window.Build = (function () { Build.prototype.toggleSidebar = function (shouldHide) { const shouldShow = typeof shouldHide === 'boolean' ? !shouldHide : undefined; + const $toggleButton = $('.js-sidebar-build-toggle-header'); this.$buildTrace .toggleClass('sidebar-expanded', shouldShow) @@ -252,6 +260,16 @@ window.Build = (function () { this.$sidebar .toggleClass('right-sidebar-expanded', shouldShow) .toggleClass('right-sidebar-collapsed', shouldHide); + + $('.js-build-page') + .toggleClass('sidebar-expanded', shouldShow) + .toggleClass('sidebar-collapsed', shouldHide); + + if (this.$sidebar.hasClass('right-sidebar-expanded')) { + $toggleButton.addClass('hidden'); + } else { + $toggleButton.removeClass('hidden'); + } }; Build.prototype.sidebarOnResize = function () { @@ -266,6 +284,7 @@ window.Build = (function () { Build.prototype.sidebarOnClick = function () { if (this.shouldHideSidebarForViewport()) this.toggleSidebar(); + this.verifyTopPosition(); }; Build.prototype.updateArtifactRemoveDate = function () { diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index ca90729c791..5f87a05067b 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -2,7 +2,6 @@ /* global UsernameValidator */ /* global ActiveTabMemoizer */ /* global ShortcutsNavigation */ -/* global Build */ /* global IssuableIndex */ /* global ShortcutsIssuable */ /* global ZenMode */ @@ -119,9 +118,6 @@ import initSettingsPanels from './settings_panels'; shortcut_handler = new ShortcutsNavigation(); new UsersSelect(); break; - case 'projects:jobs:show': - new Build(); - break; case 'projects:merge_requests:index': case 'projects:issues:index': if (gl.FilteredSearchManager && document.querySelector('.filtered-search')) { diff --git a/app/assets/javascripts/jobs/components/header.vue b/app/assets/javascripts/jobs/components/header.vue new file mode 100644 index 00000000000..5b9cf577189 --- /dev/null +++ b/app/assets/javascripts/jobs/components/header.vue @@ -0,0 +1,83 @@ +<script> + import ciHeader from '../../vue_shared/components/header_ci_component.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + + export default { + name: 'jobHeaderSection', + props: { + job: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + components: { + ciHeader, + loadingIcon, + }, + data() { + return { + actions: this.getActions(), + }; + }, + computed: { + status() { + return this.job && this.job.status; + }, + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length; + }, + }, + methods: { + getActions() { + const actions = []; + + if (this.job.new_issue_path) { + actions.push({ + label: 'New issue', + path: this.job.new_issue_path, + cssClass: 'js-new-issue btn btn-new btn-inverted visible-md-block visible-lg-block', + type: 'ujs-link', + }); + } + + if (this.job.retry_path) { + actions.push({ + label: 'Retry', + path: this.job.retry_path, + cssClass: 'js-retry-button btn btn-inverted-secondary visible-md-block visible-lg-block', + type: 'ujs-link', + }); + } + + return actions; + }, + }, + watch: { + job() { + this.actions = this.getActions(); + }, + }, + }; +</script> +<template> + <div class="js-build-header build-header top-area"> + <ci-header + v-if="shouldRenderContent" + :status="status" + item-name="Job" + :item-id="job.id" + :time="job.created_at" + :user="job.user" + :actions="actions" + :hasSidebarButton="true" + /> + <loading-icon + v-if="isLoading" + size="2" + /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar_detail_row.vue b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue new file mode 100644 index 00000000000..ab2bcd728a8 --- /dev/null +++ b/app/assets/javascripts/jobs/components/sidebar_detail_row.vue @@ -0,0 +1,31 @@ +<script> + export default { + name: 'SidebarDetailRow', + props: { + title: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: true, + }, + }, + computed: { + hasTitle() { + return this.title.length > 0; + }, + }, + }; +</script> +<template> + <p class="build-detail-row"> + <span + v-if="hasTitle" + class="build-light-text"> + {{title}}: + </span> + {{value}} + </p> +</template> diff --git a/app/assets/javascripts/jobs/components/sidebar_details_block.vue b/app/assets/javascripts/jobs/components/sidebar_details_block.vue new file mode 100644 index 00000000000..4223a8fea49 --- /dev/null +++ b/app/assets/javascripts/jobs/components/sidebar_details_block.vue @@ -0,0 +1,150 @@ +<script> + import detailRow from './sidebar_detail_row.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import timeagoMixin from '../../vue_shared/mixins/timeago'; + import { timeIntervalInWords } from '../../lib/utils/datetime_utility'; + + export default { + name: 'SidebarDetailsBlock', + props: { + job: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + }, + mixins: [ + timeagoMixin, + ], + components: { + detailRow, + loadingIcon, + }, + computed: { + shouldRenderContent() { + return !this.isLoading && Object.keys(this.job).length > 0; + }, + coverage() { + return `${this.job.coverage}%`; + }, + duration() { + return timeIntervalInWords(this.job.duration); + }, + queued() { + return timeIntervalInWords(this.job.queued); + }, + runnerId() { + return `#${this.job.runner.id}`; + }, + }, + }; +</script> +<template> + <div> + <template v-if="shouldRenderContent"> + <div + class="block retry-link" + v-if="job.retry_path || job.new_issue_path"> + <a + v-if="job.new_issue_path" + class="js-new-issue btn btn-new btn-inverted" + :href="job.new_issue_path"> + New issue + </a> + <a + v-if="job.retry_path" + class="js-retry-job btn btn-inverted-secondary" + :href="job.retry_path" + data-method="post" + rel="nofollow"> + Retry + </a> + </div> + <div class="block"> + <p + class="build-detail-row js-job-mr" + v-if="job.merge_request"> + <span + class="build-light-text"> + Merge Request: + </span> + <a :href="job.merge_request.path"> + !{{job.merge_request.iid}} + </a> + </p> + + <detail-row + class="js-job-duration" + v-if="job.duration" + title="Duration" + :value="duration" + /> + <detail-row + class="js-job-finished" + v-if="job.finished_at" + title="Finished" + :value="timeFormated(job.finished_at)" + /> + <detail-row + class="js-job-erased" + v-if="job.erased_at" + title="Erased" + :value="timeFormated(job.erased_at)" + /> + <detail-row + class="js-job-queued" + v-if="job.queued" + title="Queued" + :value="queued" + /> + <detail-row + class="js-job-runner" + v-if="job.runner" + title="Runner" + :value="runnerId" + /> + <detail-row + class="js-job-coverage" + v-if="job.coverage" + title="Coverage" + :value="coverage" + /> + <p + class="build-detail-row js-job-tags" + v-if="job.tags.length"> + <span + class="build-light-text"> + Tags: + </span> + <span + v-for="tag in job.tags" + key="tag" + class="label label-primary"> + {{tag}} + </span> + </p> + + <div + v-if="job.cancel_path" + class="btn-group prepend-top-5" + role="group"> + <a + class="js-cancel-job btn btn-sm btn-default" + :href="job.cancel_path" + data-method="post" + rel="nofollow"> + Cancel + </a> + </div> + </div> + </template> + <loading-icon + class="prepend-top-10" + v-if="isLoading" + size="2" + /> + </div> +</template> diff --git a/app/assets/javascripts/jobs/job_details_bundle.js b/app/assets/javascripts/jobs/job_details_bundle.js new file mode 100644 index 00000000000..939d17129de --- /dev/null +++ b/app/assets/javascripts/jobs/job_details_bundle.js @@ -0,0 +1,68 @@ +/* global Flash */ + +import Vue from 'vue'; +import JobMediator from './job_details_mediator'; +import jobHeader from './components/header.vue'; +import detailsBlock from './components/sidebar_details_block.vue'; + +document.addEventListener('DOMContentLoaded', () => { + const dataset = document.getElementById('js-job-details-vue').dataset; + const mediator = new JobMediator({ endpoint: dataset.endpoint }); + + mediator.fetchJob(); + + // Header + // eslint-disable-next-line no-new + new Vue({ + el: '#js-build-header-vue', + data() { + return { + mediator, + }; + }, + components: { + jobHeader, + }, + mounted() { + this.mediator.initBuildClass(); + }, + updated() { + // Wait for flash message to be appended + Vue.nextTick(() => { + if (this.mediator.build) { + this.mediator.build.verifyTopPosition(); + } + }); + }, + render(createElement) { + return createElement('job-header', { + props: { + isLoading: this.mediator.state.isLoading, + job: this.mediator.store.state.job, + }, + }); + }, + }); + + // Sidebar information block + // eslint-disable-next-line + new Vue({ + el: '#js-details-block-vue', + data() { + return { + mediator, + }; + }, + components: { + detailsBlock, + }, + render(createElement) { + return createElement('details-block', { + props: { + isLoading: this.mediator.state.isLoading, + job: this.mediator.store.state.job, + }, + }); + }, + }); +}); diff --git a/app/assets/javascripts/jobs/job_details_mediator.js b/app/assets/javascripts/jobs/job_details_mediator.js new file mode 100644 index 00000000000..063c52fac74 --- /dev/null +++ b/app/assets/javascripts/jobs/job_details_mediator.js @@ -0,0 +1,67 @@ +/* global Flash */ +/* global Build */ + +import Visibility from 'visibilityjs'; +import Poll from '../lib/utils/poll'; +import JobStore from './stores/job_store'; +import JobService from './services/job_service'; +import '../build'; + +export default class JobMediator { + constructor(options = {}) { + this.options = options; + + this.store = new JobStore(); + this.service = new JobService(options.endpoint); + + this.state = { + isLoading: false, + }; + } + + initBuildClass() { + this.build = new Build(); + } + + fetchJob() { + this.poll = new Poll({ + resource: this.service, + method: 'getJob', + successCallback: this.successCallback.bind(this), + errorCallback: this.errorCallback.bind(this), + }); + + if (!Visibility.hidden()) { + this.state.isLoading = true; + this.poll.makeRequest(); + } else { + this.getJob(); + } + + Visibility.change(() => { + if (!Visibility.hidden()) { + this.poll.restart(); + } else { + this.poll.stop(); + } + }); + } + + getJob() { + return this.service.getJob() + .then(response => this.successCallback(response)) + .catch(() => this.errorCallback()); + } + + successCallback(response) { + const data = response.json(); + this.state.isLoading = false; + this.store.storeJob(data); + } + + errorCallback() { + this.state.isLoading = false; + + return new Flash('An error occurred while fetching the job.'); + } +} diff --git a/app/assets/javascripts/jobs/services/job_service.js b/app/assets/javascripts/jobs/services/job_service.js new file mode 100644 index 00000000000..eaf1c6e500a --- /dev/null +++ b/app/assets/javascripts/jobs/services/job_service.js @@ -0,0 +1,14 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; + +Vue.use(VueResource); + +export default class JobService { + constructor(endpoint) { + this.job = Vue.resource(endpoint); + } + + getJob() { + return this.job.get(); + } +} diff --git a/app/assets/javascripts/jobs/stores/job_store.js b/app/assets/javascripts/jobs/stores/job_store.js new file mode 100644 index 00000000000..766194b8387 --- /dev/null +++ b/app/assets/javascripts/jobs/stores/job_store.js @@ -0,0 +1,11 @@ +export default class JobStore { + constructor() { + this.state = { + job: {}, + }; + } + + storeJob(job = {}) { + this.state.job = job; + } +} diff --git a/app/assets/javascripts/lib/utils/datetime_utility.js b/app/assets/javascripts/lib/utils/datetime_utility.js index 40eadd9396c..54c0da3fc9c 100644 --- a/app/assets/javascripts/lib/utils/datetime_utility.js +++ b/app/assets/javascripts/lib/utils/datetime_utility.js @@ -146,3 +146,24 @@ window.dateFormat = dateFormat; }; })(window); }).call(window); + +/** + * Port of ruby helper time_interval_in_words. + * + * @param {Number} seconds + * @return {String} + */ +// eslint-disable-next-line import/prefer-default-export +export function timeIntervalInWords(intervalInSeconds) { + const secondsInteger = parseInt(intervalInSeconds, 10); + const minutes = Math.floor(secondsInteger / 60); + const seconds = secondsInteger - (minutes * 60); + let text = ''; + + if (minutes >= 1) { + text = `${minutes} ${gl.text.pluralize('minute', minutes)} ${seconds} ${gl.text.pluralize('second', seconds)}`; + } else { + text = `${seconds} ${gl.text.pluralize('second', seconds)}`; + } + return text; +} diff --git a/app/assets/javascripts/pipelines/components/header_component.vue b/app/assets/javascripts/pipelines/components/header_component.vue index 4f6c5c177cf..2a1ecac3707 100644 --- a/app/assets/javascripts/pipelines/components/header_component.vue +++ b/app/assets/javascripts/pipelines/components/header_component.vue @@ -91,7 +91,7 @@ export default { @actionClicked="postAction" /> <loading-icon - v-else + v-if="isLoading" size="2"/> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/header_ci_component.vue b/app/assets/javascripts/vue_shared/components/header_ci_component.vue index fe6d6a792e7..1d4d90f75b6 100644 --- a/app/assets/javascripts/vue_shared/components/header_ci_component.vue +++ b/app/assets/javascripts/vue_shared/components/header_ci_component.vue @@ -40,6 +40,11 @@ export default { required: false, default: () => [], }, + hasSidebarButton: { + type: Boolean, + required: false, + default: false, + }, }, mixins: [ @@ -66,8 +71,9 @@ export default { }, }; </script> + <template> - <header class="page-content-header"> + <header class="page-content-header ci-header-container"> <section class="header-main-content"> <ci-icon-badge :status="status" /> @@ -102,7 +108,7 @@ export default { </section> <section - class="header-action-button nav-controls" + class="header-action-buttons" v-if="actions.length"> <template v-for="action in actions"> @@ -113,6 +119,15 @@ export default { {{action.label}} </a> + <a + v-if="action.type === 'ujs-link'" + :href="action.path" + data-method="post" + rel="nofollow" + :class="action.cssClass"> + {{action.label}} + </a> + <button v-else="action.type === 'button'" @click="onClickAction(action)" @@ -120,7 +135,6 @@ export default { :class="action.cssClass" type="button"> {{action.label}} - <i v-show="action.isLoading" class="fa fa-spin fa-spinner" @@ -128,6 +142,18 @@ export default { </i> </button> </template> + <button + v-if="hasSidebarButton" + type="button" + class="btn btn-default visible-xs-block visible-sm-block sidebar-toggle-btn js-sidebar-build-toggle js-sidebar-build-toggle-header" + aria-label="Toggle Sidebar" + id="toggleSidebar"> + <i + class="fa fa-angle-double-left" + aria-hidden="true" + aria-labelledby="toggleSidebar"> + </i> + </button> </section> </header> </template> diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index d931a78e112..203fd6d07e4 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -153,15 +153,16 @@ } .environment-information { - background-color: $gray-light; border: 1px solid $border-color; - padding: 12px $gl-padding; + padding: 8px $gl-padding 12px; border-radius: $border-radius-default; svg { position: relative; - top: 1px; + top: 5px; margin-right: 5px; + width: 22px; + height: 22px; } } @@ -175,54 +176,31 @@ } } -.status-message { - display: inline-block; - color: $white-light; - - .status-icon { - display: inline-block; - width: 16px; - height: 33px; +.build-header { + .ci-header-container, + .header-action-buttons { + display: flex; } - .status-text { - float: left; - opacity: 0; - margin-right: 10px; - font-weight: normal; - line-height: 1.8; - transition: opacity 1s ease-out; - - &.animate { - animation: fade-out-status 2s ease; - } + .ci-header-container { + min-height: 54px; } - &:hover .status-text { - opacity: 1; + .page-content-header { + padding: 10px 0 9px; } -} - -.build-header { - position: relative; - padding: 0; - display: flex; - min-height: 58px; - align-items: center; - - @media (max-width: $screen-sm-max) { - padding-right: 40px; - margin-top: 6px; - .btn-inverted { - display: none; + .header-action-buttons { + @media (max-width: $screen-xs-max) { + .sidebar-toggle-btn { + margin-top: 0; + margin-left: 10px; + max-height: 34px; + } } } .header-content { - flex: 1; - line-height: 1.8; - a { color: $gl-text-color; @@ -245,7 +223,7 @@ } .right-sidebar.build-sidebar { - padding: $gl-padding 0; + padding: 0; &.right-sidebar-collapsed { display: none; @@ -258,6 +236,10 @@ .block { width: 100%; + &:last-child { + border-bottom: 1px solid $border-gray-normal; + } + &.coverage { padding: 0 16px 11px; } @@ -267,34 +249,39 @@ } } - .js-build-variable { + .trigger-build-variable { color: $code-color; } - .js-build-value { + .trigger-build-value { padding: 2px 4px; color: $black; background-color: $white-light; } - .build-sidebar-header { - padding: 0 $gl-padding $gl-padding; - - .gutter-toggle { - margin-top: 0; - } + .label { + margin-left: 2px; } .retry-link { - color: $gl-link-color; display: none; - &:hover { - text-decoration: underline; + .btn-inverted-secondary { + color: $blue-500; + + &:hover { + color: $white-light; + } } @media (max-width: $screen-sm-max) { display: block; + + .btn { + i { + margin-left: 5px; + } + } } } @@ -318,6 +305,12 @@ left: $gl-padding; width: auto; } + + svg { + position: relative; + top: 2px; + margin-right: 3px; + } } .builds-container { @@ -379,6 +372,10 @@ } } } + + .link-commit { + color: $blue-600; + } } .build-sidebar { diff --git a/app/assets/stylesheets/pages/pipelines.scss b/app/assets/stylesheets/pages/pipelines.scss index 71b02002235..cd9382e8de5 100644 --- a/app/assets/stylesheets/pages/pipelines.scss +++ b/app/assets/stylesheets/pages/pipelines.scss @@ -986,10 +986,17 @@ } } -.pipeline-header-container { +.ci-header-container { min-height: 55px; .text-center { padding-top: 12px; } + + .header-action-buttons { + .btn, + a { + margin-left: 10px; + } + } } diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 55c16f7e1fd..36c87eb0d0c 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base false end + # To be overriden when inherrited from + def cancelable? + false + end + def stuck? false end diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 0063920e603..514c4c2e35f 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -34,10 +34,8 @@ class BuildDetailsEntity < BuildEntity private def build_failed_issue_options - { - title: "Build Failed ##{build.id}", - description: namespace_project_job_url(project.namespace, project, build) - } + { title: "Build Failed ##{build.id}", + description: namespace_project_job_path(project.namespace, project, build) } end def current_user diff --git a/app/serializers/build_entity.rb b/app/serializers/build_entity.rb index c01efa9dd5c..67001f4547d 100644 --- a/app/serializers/build_entity.rb +++ b/app/serializers/build_entity.rb @@ -8,10 +8,14 @@ class BuildEntity < Grape::Entity path_to(:namespace_project_job, build) end - expose :retry_path, if: -> (*) { build&.retryable? } do |build| + expose :retry_path, if: -> (*) { retryable? } do |build| path_to(:retry_namespace_project_job, build) end + expose :cancel_path, if: -> (*) { cancelable? } do |build| + path_to(:cancel_namespace_project_job, build) + end + expose :play_path, if: -> (*) { playable? } do |build| path_to(:play_namespace_project_job, build) end @@ -25,6 +29,14 @@ class BuildEntity < Grape::Entity alias_method :build, :object + def cancelable? + build.cancelable? && can?(request.current_user, :update_build, build) + end + + def retryable? + build.retryable? && can?(request.current_user, :update_build, build) + end + def playable? build.playable? && can?(request.current_user, :update_build, build) end diff --git a/app/views/projects/jobs/_sidebar.html.haml b/app/views/projects/jobs/_sidebar.html.haml index 09d4ddc243b..8b9e6e57ec4 100644 --- a/app/views/projects/jobs/_sidebar.html.haml +++ b/app/views/projects/jobs/_sidebar.html.haml @@ -1,19 +1,15 @@ - builds = @build.pipeline.builds.to_a %aside.right-sidebar.right-sidebar-expanded.build-sidebar.js-build-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" } } - .block.build-sidebar-header.visible-xs-block.visible-sm-block.append-bottom-default - Job - %strong ##{@build.id} - %a.gutter-toggle.pull-right.js-sidebar-build-toggle{ href: "#" } - = icon('angle-double-right') - - if @build.coverage - .block.coverage - .title - Test coverage - %p.build-detail-row - #{@build.coverage}% - .blocks-container + .block + %strong + = @build.name + %a.gutter-toggle.pull-right.visible-xs-block.visible-sm-block.js-sidebar-build-toggle{ href: "#", 'aria-label': 'Toggle Sidebar', role: 'button' } + = icon('angle-double-right') + + #js-details-block-vue + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) .block{ class: ("block-first" if !@build.coverage) } .title @@ -40,37 +36,6 @@ = link_to browse_namespace_project_job_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do Browse - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } - .title - Job details - - if can?(current_user, :update_build, @build) && @build.retryable? - = link_to "Retry job", retry_namespace_project_job_path(@project.namespace, @project, @build), class: 'pull-right retry-link', method: :post - - if @build.merge_request - %p.build-detail-row - %span.build-light-text Merge Request: - = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'bold' - - if @build.duration - %p.build-detail-row - %span.build-light-text Duration: - = time_interval_in_words(@build.duration) - - if @build.finished_at - %p.build-detail-row - %span.build-light-text Finished: - #{time_ago_with_tooltip(@build.finished_at)} - - if @build.erased_at - %p.build-detail-row - %span.build-light-text Erased: - #{time_ago_with_tooltip(@build.erased_at)} - %p.build-detail-row - %span.build-light-text Runner: - - if @build.runner && current_user && current_user.admin - = link_to "##{@build.runner.id}", admin_runner_path(@build.runner.id) - - elsif @build.runner - \##{@build.runner.id} - .btn-group.btn-group-justified{ role: :group } - - if @build.active? - = link_to "Cancel", cancel_namespace_project_job_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post - - if @build.trigger_request .build-widget %h4.title @@ -87,26 +52,29 @@ - @build.trigger_request.variables.each do |key, value| .hide.js-build - .js-build-variable= key - .js-build-value= value + .js-build-variable.trigger-build-variable= key + .js-build-value.trigger-build-value= value .block - .title - Commit title + %p + Commit + = link_to @build.pipeline.short_sha, namespace_project_commit_path(@project.namespace, @project, @build.pipeline.sha), class: 'commit-sha link-commit' + = clipboard_button(text: @build.pipeline.short_sha, title: "Copy commit SHA to clipboard") + - if @build.merge_request + in + = link_to "#{@build.merge_request.to_reference}", merge_request_path(@build.merge_request), class: 'link-commit' + %p.build-light-text.append-bottom-0 #{@build.pipeline.git_commit_title} - - if @build.tags.any? - .block - .title - Tags - - @build.tag_list.each do |tag| - %span.label.label-primary - = tag - - if @build.pipeline.stages_count > 1 .dropdown.build-dropdown - .title Stage + .title + %span{ class: "ci-status-icon-#{@build.pipeline.status}" } + = ci_icon_for_status(@build.pipeline.status) + = link_to "##{@build.pipeline.id}", namespace_project_pipeline_path(@project.namespace, @project, @build.pipeline), class: 'link-commit' + from + = link_to "#{@build.pipeline.ref}", namespace_project_branch_path(@project.namespace, @project, @build.pipeline.ref), class: 'link-commit' %button.dropdown-menu-toggle{ type: 'button', 'data-toggle' => 'dropdown' } %span.stage-selection More = icon('chevron-down') diff --git a/app/views/projects/jobs/show.html.haml b/app/views/projects/jobs/show.html.haml index 987068dc18e..c73bae0a2c9 100644 --- a/app/views/projects/jobs/show.html.haml +++ b/app/views/projects/jobs/show.html.haml @@ -3,9 +3,8 @@ = render "projects/pipelines/head" %div{ class: container_class } - .build-page - = render "header" - + .build-page.js-build-page + #js-build-header-vue - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning.js-build-stuck @@ -47,47 +46,52 @@ - if environment.try(:last_deployment) and will overwrite the #{deployment_link(environment.last_deployment, text: 'latest deployment')} - .prepend-top-default.js-build-erased - - if @build.erased? + - if @build.erased? + .prepend-top-default.js-build-erased .erased.alert.alert-warning - if @build.erased_by_user? Job has been erased by #{link_to(@build.erased_by_name, user_path(@build.erased_by))} #{time_ago_with_tooltip(@build.erased_at)} - else Job has been erased #{time_ago_with_tooltip(@build.erased_at)} - .prepend-top-default - .build-trace-container#build-trace - .top-bar.sticky - .js-truncated-info.truncated-info.hidden< - Showing last - %span.js-truncated-info-size.truncated-info-size>< - KiB of log - - %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw - .controllers - - if @build.has_trace? - = link_to raw_namespace_project_job_path(@project.namespace, @project, @build), - title: 'Show complete raw', - data: { placement: 'top', container: 'body' }, - class: 'js-raw-link-controller has-tooltip controllers-buttons' do - = icon('file-text-o') + .build-trace-container#build-trace + .top-bar.sticky + .js-truncated-info.truncated-info.hidden< + Showing last + %span.js-truncated-info-size.truncated-info-size>< + KiB of log - + %a.js-raw-link.raw-link{ href: raw_namespace_project_job_path(@project.namespace, @project, @build) }>< Complete Raw + .controllers + - if @build.has_trace? + = link_to raw_namespace_project_job_path(@project.namespace, @project, @build), + title: 'Show complete raw', + data: { placement: 'top', container: 'body' }, + class: 'js-raw-link-controller has-tooltip controllers-buttons' do + = icon('file-text-o') - - if can?(current_user, :update_build, @project) && @build.erasable? - = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), - method: :post, - data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, - title: 'Erase job log', - class: 'has-tooltip js-erase-link controllers-buttons' do - = icon('trash') - .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} } - %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } - = custom_icon('scroll_up') - .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} } - %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } - = custom_icon('scroll_down') - .bash.sticky.js-scroll-container - %code.js-build-output - .build-loader-animation.js-build-refresh + - if can?(current_user, :update_build, @project) && @build.erasable? + = link_to erase_namespace_project_job_path(@project.namespace, @project, @build), + method: :post, + data: { confirm: 'Are you sure you want to erase this build?', placement: 'top', container: 'body' }, + title: 'Erase job log', + class: 'has-tooltip js-erase-link controllers-buttons' do + = icon('trash') + .has-tooltip.controllers-buttons{ title: 'Scroll to top', data: { placement: 'top', container: 'body'} } + %button.js-scroll-up.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } + = custom_icon('scroll_up') + .has-tooltip.controllers-buttons{ title: 'Scroll to bottom', data: { placement: 'top', container: 'body'} } + %button.js-scroll-down.btn-scroll.btn-transparent.btn-blank{ type: 'button', disabled: true } + = custom_icon('scroll_down') + .bash.sticky.js-scroll-container + %code.js-build-output + .build-loader-animation.js-build-refresh = render "sidebar" .js-build-options{ data: javascript_build_options } + +#js-job-details-vue{ data: { endpoint: namespace_project_job_path(@project.namespace, @project, @build, format: :json) } } + +- content_for :page_specific_javascripts do + = webpack_bundle_tag('common_vue') + = webpack_bundle_tag('job_details') |