summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-06-12 09:20:19 +0000
committerPhil Hughes <me@iamphill.com>2017-06-12 09:20:19 +0000
commit452202e36d3e20755b099a718a92d3f7b80fabb8 (patch)
tree5cc8bd9c5d810a645f44f515c4310731fa01785f /app
parentd25f6fcf629bd773ccac49a799393479c48f4673 (diff)
downloadgitlab-ce-452202e36d3e20755b099a718a92d3f7b80fabb8.tar.gz
Improve Job detail view to make it refreshed in real-time instead of reloading
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/build.js31
-rw-r--r--app/assets/javascripts/dispatcher.js4
-rw-r--r--app/assets/javascripts/jobs/components/header.vue83
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_detail_row.vue31
-rw-r--r--app/assets/javascripts/jobs/components/sidebar_details_block.vue150
-rw-r--r--app/assets/javascripts/jobs/job_details_bundle.js68
-rw-r--r--app/assets/javascripts/jobs/job_details_mediator.js67
-rw-r--r--app/assets/javascripts/jobs/services/job_service.js14
-rw-r--r--app/assets/javascripts/jobs/stores/job_store.js11
-rw-r--r--app/assets/javascripts/lib/utils/datetime_utility.js21
-rw-r--r--app/assets/javascripts/pipelines/components/header_component.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/header_ci_component.vue32
-rw-r--r--app/assets/stylesheets/pages/builds.scss103
-rw-r--r--app/assets/stylesheets/pages/pipelines.scss9
-rw-r--r--app/models/commit_status.rb5
-rw-r--r--app/serializers/build_details_entity.rb6
-rw-r--r--app/serializers/build_entity.rb14
-rw-r--r--app/views/projects/jobs/_sidebar.html.haml80
-rw-r--r--app/views/projects/jobs/show.html.haml76
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')