summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/boards/components/board_sidebar.js3
-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/pipelines/components/nav_controls.js52
-rw-r--r--app/assets/javascripts/pipelines/components/nav_controls.vue54
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.js72
-rw-r--r--app/assets/javascripts/pipelines/components/navigation_tabs.vue76
-rw-r--r--app/assets/javascripts/pipelines/pipelines.js4
-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.scss20
-rw-r--r--app/assets/stylesheets/pages/status.scss10
-rw-r--r--app/controllers/concerns/spammable_actions.rb10
-rw-r--r--app/controllers/dashboard/todos_controller.rb5
-rw-r--r--app/helpers/form_helper.rb2
-rw-r--r--app/helpers/todos_helper.rb2
-rw-r--r--app/helpers/u2f_helper.rb2
-rw-r--r--app/models/commit_status.rb7
-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
-rw-r--r--app/workers/background_migration_worker.rb23
32 files changed, 821 insertions, 314 deletions
diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js
index 386102032cb..c7afd4ead6b 100644
--- a/app/assets/javascripts/boards/components/board_sidebar.js
+++ b/app/assets/javascripts/boards/components/board_sidebar.js
@@ -32,9 +32,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({
showSidebar () {
return Object.keys(this.issue).length;
},
- assigneeId() {
- return this.issue.assignee ? this.issue.assignee.id : 0;
- },
milestoneTitle() {
return this.issue.milestone ? this.issue.milestone.title : 'No Milestone';
}
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/pipelines/components/nav_controls.js b/app/assets/javascripts/pipelines/components/nav_controls.js
deleted file mode 100644
index 6aa10531034..00000000000
--- a/app/assets/javascripts/pipelines/components/nav_controls.js
+++ /dev/null
@@ -1,52 +0,0 @@
-export default {
- props: {
- newPipelinePath: {
- type: String,
- required: true,
- },
-
- hasCiEnabled: {
- type: Boolean,
- required: true,
- },
-
- helpPagePath: {
- type: String,
- required: true,
- },
-
- ciLintPath: {
- type: String,
- required: true,
- },
-
- canCreatePipeline: {
- type: Boolean,
- required: true,
- },
- },
-
- template: `
- <div class="nav-controls">
- <a
- v-if="canCreatePipeline"
- :href="newPipelinePath"
- class="btn btn-create">
- Run Pipeline
- </a>
-
- <a
- v-if="!hasCiEnabled"
- :href="helpPagePath"
- class="btn btn-info">
- Get started with Pipelines
- </a>
-
- <a
- :href="ciLintPath"
- class="btn btn-default">
- CI Lint
- </a>
- </div>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/nav_controls.vue b/app/assets/javascripts/pipelines/components/nav_controls.vue
new file mode 100644
index 00000000000..632fc167f2b
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/nav_controls.vue
@@ -0,0 +1,54 @@
+<script>
+export default {
+ name: 'PipelineNavControls',
+ props: {
+ newPipelinePath: {
+ type: String,
+ required: true,
+ },
+
+ hasCiEnabled: {
+ type: Boolean,
+ required: true,
+ },
+
+ helpPagePath: {
+ type: String,
+ required: true,
+ },
+
+ ciLintPath: {
+ type: String,
+ required: true,
+ },
+
+ canCreatePipeline: {
+ type: Boolean,
+ required: true,
+ },
+ },
+};
+</script>
+<template>
+ <div class="nav-controls">
+ <a
+ v-if="canCreatePipeline"
+ :href="newPipelinePath"
+ class="btn btn-create">
+ Run Pipeline
+ </a>
+
+ <a
+ v-if="!hasCiEnabled"
+ :href="helpPagePath"
+ class="btn btn-info">
+ Get started with Pipelines
+ </a>
+
+ <a
+ :href="ciLintPath"
+ class="btn btn-default">
+ CI Lint
+ </a>
+ </div>
+</template>
diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.js b/app/assets/javascripts/pipelines/components/navigation_tabs.js
deleted file mode 100644
index 1626ae17a30..00000000000
--- a/app/assets/javascripts/pipelines/components/navigation_tabs.js
+++ /dev/null
@@ -1,72 +0,0 @@
-export default {
- props: {
- scope: {
- type: String,
- required: true,
- },
-
- count: {
- type: Object,
- required: true,
- },
-
- paths: {
- type: Object,
- required: true,
- },
- },
-
- mounted() {
- $(document).trigger('init.scrolling-tabs');
- },
-
- template: `
- <ul class="nav-links scrolling-tabs">
- <li
- class="js-pipelines-tab-all"
- :class="{ 'active': scope === 'all'}">
- <a :href="paths.allPath">
- All
- <span class="badge js-totalbuilds-count">
- {{count.all}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-pending"
- :class="{ 'active': scope === 'pending'}">
- <a :href="paths.pendingPath">
- Pending
- <span class="badge">
- {{count.pending}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-running"
- :class="{ 'active': scope === 'running'}">
- <a :href="paths.runningPath">
- Running
- <span class="badge">
- {{count.running}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-finished"
- :class="{ 'active': scope === 'finished'}">
- <a :href="paths.finishedPath">
- Finished
- <span class="badge">
- {{count.finished}}
- </span>
- </a>
- </li>
- <li class="js-pipelines-tab-branches"
- :class="{ 'active': scope === 'branches'}">
- <a :href="paths.branchesPath">Branches</a>
- </li>
- <li class="js-pipelines-tab-tags"
- :class="{ 'active': scope === 'tags'}">
- <a :href="paths.tagsPath">Tags</a>
- </li>
- </ul>
- `,
-};
diff --git a/app/assets/javascripts/pipelines/components/navigation_tabs.vue b/app/assets/javascripts/pipelines/components/navigation_tabs.vue
new file mode 100644
index 00000000000..d2f6d47f043
--- /dev/null
+++ b/app/assets/javascripts/pipelines/components/navigation_tabs.vue
@@ -0,0 +1,76 @@
+<script>
+export default {
+ name: 'PipelineNavigationTabs',
+ props: {
+ scope: {
+ type: String,
+ required: true,
+ },
+ count: {
+ type: Object,
+ required: true,
+ },
+ paths: {
+ type: Object,
+ required: true,
+ },
+ },
+ mounted() {
+ $(document).trigger('init.scrolling-tabs');
+ },
+};
+</script>
+<template>
+ <ul class="nav-links scrolling-tabs">
+ <li
+ class="js-pipelines-tab-all"
+ :class="{ active: scope === 'all'}">
+ <a :href="paths.allPath">
+ All
+ <span class="badge js-totalbuilds-count">
+ {{count.all}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-pending"
+ :class="{ active: scope === 'pending'}">
+ <a :href="paths.pendingPath">
+ Pending
+ <span class="badge">
+ {{count.pending}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-running"
+ :class="{ active: scope === 'running'}">
+ <a :href="paths.runningPath">
+ Running
+ <span class="badge">
+ {{count.running}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-finished"
+ :class="{ active: scope === 'finished'}">
+ <a :href="paths.finishedPath">
+ Finished
+ <span class="badge">
+ {{count.finished}}
+ </span>
+ </a>
+ </li>
+ <li
+ class="js-pipelines-tab-branches"
+ :class="{ active: scope === 'branches'}">
+ <a :href="paths.branchesPath">Branches</a>
+ </li>
+ <li
+ class="js-pipelines-tab-tags"
+ :class="{ active: scope === 'tags'}">
+ <a :href="paths.tagsPath">Tags</a>
+ </li>
+ </ul>
+</template>
diff --git a/app/assets/javascripts/pipelines/pipelines.js b/app/assets/javascripts/pipelines/pipelines.js
index 23b967b4b32..b530461837c 100644
--- a/app/assets/javascripts/pipelines/pipelines.js
+++ b/app/assets/javascripts/pipelines/pipelines.js
@@ -5,8 +5,8 @@ import pipelinesTableComponent from '../vue_shared/components/pipelines_table';
import tablePagination from '../vue_shared/components/table_pagination.vue';
import emptyState from './components/empty_state.vue';
import errorState from './components/error_state.vue';
-import navigationTabs from './components/navigation_tabs';
-import navigationControls from './components/nav_controls';
+import navigationTabs from './components/navigation_tabs.vue';
+import navigationControls from './components/nav_controls.vue';
import loadingIcon from '../vue_shared/components/loading_icon.vue';
import Poll from '../lib/utils/poll';
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 58b458cd837..cd9382e8de5 100644
--- a/app/assets/stylesheets/pages/pipelines.scss
+++ b/app/assets/stylesheets/pages/pipelines.scss
@@ -545,12 +545,13 @@
border: 1px solid $border-color;
border-radius: 30px;
background-color: $white-light;
+ }
- &:hover {
- background-color: $stage-hover-bg;
- border: 1px solid $stage-hover-border;
- color: $gl-text-color;
- }
+ a.build-content:hover,
+ button.build-content:hover {
+ background-color: $stage-hover-bg;
+ border: 1px solid $stage-hover-border;
+ color: $gl-text-color;
}
@@ -985,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/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss
index 4a284247143..4ed8617b6a3 100644
--- a/app/assets/stylesheets/pages/status.scss
+++ b/app/assets/stylesheets/pages/status.scss
@@ -19,8 +19,7 @@
overflow: visible;
}
- &.ci-failed,
- &.ci-failed_with_warnings {
+ &.ci-failed {
color: $red-500;
border-color: $red-500;
@@ -39,8 +38,7 @@
}
}
- &.ci-success,
- &.ci-success_with_warnings {
+ &.ci-success {
color: $green-600;
border-color: $green-500;
@@ -73,7 +71,9 @@
}
}
- &.ci-pending {
+ &.ci-pending,
+ &.ci-success_with_warnings,
+ &.ci-failed_with_warnings {
color: $orange-600;
border-color: $orange-500;
diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb
index d0a692070d9..b68d76aeff0 100644
--- a/app/controllers/concerns/spammable_actions.rb
+++ b/app/controllers/concerns/spammable_actions.rb
@@ -17,10 +17,18 @@ module SpammableActions
private
+ def ensure_spam_config_loaded!
+ return @spam_config_loaded if defined?(@spam_config_loaded)
+
+ @spam_config_loaded = Gitlab::Recaptcha.load_configurations!
+ end
+
def recaptcha_check_with_fallback(&fallback)
if spammable.valid?
redirect_to spammable
elsif render_recaptcha?
+ ensure_spam_config_loaded!
+
if params[:recaptcha_verification]
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
end
@@ -35,7 +43,7 @@ module SpammableActions
default_params = { request: request }
recaptcha_check = params[:recaptcha_verification] &&
- Gitlab::Recaptcha.load_configurations! &&
+ ensure_spam_config_loaded! &&
verify_recaptcha
return default_params unless recaptcha_check
diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb
index 623392c1240..28c90548cc1 100644
--- a/app/controllers/dashboard/todos_controller.rb
+++ b/app/controllers/dashboard/todos_controller.rb
@@ -47,11 +47,6 @@ class Dashboard::TodosController < Dashboard::ApplicationController
render json: todos_counts
end
- # Used in TodosHelper also
- def self.todos_count_format(count)
- count >= 100 ? '99+' : count
- end
-
private
def find_todos
diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb
index 53962b84618..014fc46b130 100644
--- a/app/helpers/form_helper.rb
+++ b/app/helpers/form_helper.rb
@@ -29,7 +29,7 @@ module FormHelper
current_user: true,
project_id: issuable.project.try(:id),
field_name: "#{issuable.class.model_name.param_key}[assignee_ids][]",
- default_label: 'Assignee',
+ default_label: 'Unassigned',
'max-select': 1,
'dropdown-header': 'Assignee',
multi_select: true,
diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb
index 19286fadb19..3d1b3a4711a 100644
--- a/app/helpers/todos_helper.rb
+++ b/app/helpers/todos_helper.rb
@@ -4,7 +4,7 @@ module TodosHelper
end
def todos_count_format(count)
- count > 99 ? '99+' : count
+ count > 99 ? '99+' : count.to_s
end
def todos_done_count
diff --git a/app/helpers/u2f_helper.rb b/app/helpers/u2f_helper.rb
index 143b4ca6b51..81bfe5d4eeb 100644
--- a/app/helpers/u2f_helper.rb
+++ b/app/helpers/u2f_helper.rb
@@ -1,5 +1,5 @@
module U2fHelper
def inject_u2f_api?
- browser.chrome? && browser.version.to_i >= 41 && !browser.device.mobile?
+ ((browser.chrome? && browser.version.to_i >= 41) || (browser.opera? && browser.version.to_i >= 40)) && !browser.device.mobile?
end
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 55c16f7e1fd..cb425706a9e 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -15,7 +15,7 @@ class CommitStatus < ActiveRecord::Base
validates :pipeline, presence: true, unless: :importing?
- validates :name, presence: true
+ validates :name, presence: true, unless: :importing?
alias_attribute :author, :user
@@ -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')
diff --git a/app/workers/background_migration_worker.rb b/app/workers/background_migration_worker.rb
new file mode 100644
index 00000000000..e85e221d353
--- /dev/null
+++ b/app/workers/background_migration_worker.rb
@@ -0,0 +1,23 @@
+class BackgroundMigrationWorker
+ include Sidekiq::Worker
+ include DedicatedSidekiqQueue
+
+ # Schedules a number of jobs in bulk
+ #
+ # The `jobs` argument should be an Array of Arrays, each sub-array must be in
+ # the form:
+ #
+ # [migration-class, [arg1, arg2, ...]]
+ def self.perform_bulk(*jobs)
+ Sidekiq::Client.push_bulk('class' => self,
+ 'queue' => sidekiq_options['queue'],
+ 'args' => jobs)
+ end
+
+ # Performs the background migration.
+ #
+ # See Gitlab::BackgroundMigration.perform for more information.
+ def perform(class_name, arguments = [])
+ Gitlab::BackgroundMigration.perform(class_name, arguments)
+ end
+end