Improve Job detail view to make it refreshed in real-time instead of reloading

This commit is contained in:
Filipa Lacerda 2017-06-12 09:20:19 +00:00 committed by Phil Hughes
parent d25f6fcf62
commit 452202e36d
36 changed files with 1174 additions and 316 deletions

View file

@ -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 () {

View file

@ -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')) {

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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,
},
});
},
});
});

View file

@ -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.');
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,11 @@
export default class JobStore {
constructor() {
this.state = {
job: {},
};
}
storeJob(job = {}) {
this.state.job = job;
}
}

View file

@ -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;
}

View file

@ -91,7 +91,7 @@ export default {
@actionClicked="postAction"
/>
<loading-icon
v-else
v-if="isLoading"
size="2"/>
</div>
</template>

View file

@ -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>

View file

@ -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;
}
.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;
}
}
&:hover .status-text {
opacity: 1;
}
}
.build-header {
position: relative;
padding: 0;
display: flex;
min-height: 58px;
align-items: center;
.ci-header-container,
.header-action-buttons {
display: flex;
}
@media (max-width: $screen-sm-max) {
padding-right: 40px;
margin-top: 6px;
.ci-header-container {
min-height: 54px;
}
.btn-inverted {
display: none;
.page-content-header {
padding: 10px 0 9px;
}
.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 {

View file

@ -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;
}
}
}

View file

@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base
false
end
# To be overriden when inherrited from
def cancelable?
false
end
def stuck?
false
end

View file

@ -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

View file

@ -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

View file

@ -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')

View file

@ -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')

View file

@ -0,0 +1,4 @@
---
title: Adds realtime feature to job show view header and sidebar info. Updates UX.
merge_request:
author:

View file

@ -44,6 +44,7 @@ var config = {
groups_list: './groups_list.js',
issue_show: './issue_show/index.js',
integrations: './integrations',
job_details: './jobs/job_details_bundle.js',
locale: './locale/index.js',
main: './main.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
@ -158,6 +159,7 @@ var config = {
'filtered_search',
'groups',
'issue_show',
'job_details',
'merge_conflicts',
'notebook_viewer',
'pdf_viewer',

View file

@ -27,6 +27,7 @@ Feature: Project Builds Permissions
When I visit project builds page
Then page status code should be 404
@javascript
Scenario: I try to visit build details of internal project with access to builds
Given The project is internal
And public access for builds is enabled

View file

@ -6,16 +6,19 @@ Feature: Project Builds Summary
And project has coverage enabled
And project has a recent build
@javascript
Scenario: I browse build details page
When I visit recent build details page
Then I see details of a build
And I see build trace
@javascript
Scenario: I browse project builds page
When I visit project builds page
Then I see coverage
Then I see button to CI Lint
@javascript
Scenario: I erase a build
Given recent build is successful
And recent build has a build trace

View file

@ -13,7 +13,7 @@ class Spinach::Features::ProjectBuildsSummary < Spinach::FeatureSteps
step 'I see button to CI Lint' do
page.within('.nav-controls') do
ci_lint_tool_link = page.find_link('CI lint')
expect(ci_lint_tool_link[:href]).to eq ci_lint_path
expect(ci_lint_tool_link[:href]).to end_with(ci_lint_path)
end
end

View file

@ -5,6 +5,7 @@ feature 'Jobs', :feature do
let(:user) { create(:user) }
let(:user_access_level) { :developer }
let(:project) { create(:project) }
let(:namespace) { project.namespace }
let(:pipeline) { create(:ci_pipeline, project: project) }
let(:build) { create(:ci_build, :trace, pipeline: pipeline) }
@ -113,10 +114,16 @@ feature 'Jobs', :feature do
describe "GET /:project/jobs/:id" do
context "Job from project" do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do
visit namespace_project_job_path(project.namespace, project, build)
end
it 'shows status name', :js do
expect(page).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'shows commit`s data' do
expect(page.status_code).to eq(200)
expect(page).to have_content pipeline.sha[0..7]
@ -129,6 +136,48 @@ feature 'Jobs', :feature do
end
end
context 'when job is not running', :js do
let(:build) { create(:ci_build, :success, pipeline: pipeline) }
before do
visit namespace_project_job_path(project.namespace, project, build)
end
it 'shows retry button' do
expect(page).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(page).not_to have_link('New issue')
end
end
context 'if build failed' do
let(:build) { create(:ci_build, :failed, pipeline: pipeline) }
before do
visit namespace_project_job_path(namespace, project, build)
end
it 'shows New issue button' do
expect(page).to have_link('New issue')
end
it 'links to issues/new with the title and description filled in' do
button_title = "Build Failed ##{build.id}"
build_path = namespace_project_job_path(namespace, project, build)
options = { issue: { title: button_title, description: build_path } }
href = new_namespace_project_issue_path(namespace, project, options)
page.within('.header-action-buttons') do
expect(find('.js-new-issue')['href']).to include(href)
end
end
end
end
context "Job from other project" do
before do
visit namespace_project_job_path(project.namespace, project, build2)
@ -305,63 +354,38 @@ feature 'Jobs', :feature do
end
end
describe "POST /:project/jobs/:id/cancel" do
describe "POST /:project/jobs/:id/cancel", :js do
context "Job from project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link "Cancel"
find('.js-cancel-job').click()
end
it 'loads the page and shows all needed controls' do
expect(page.status_code).to eq(200)
expect(page).to have_content 'canceled'
expect(page).to have_content 'Retry'
end
end
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
page.driver.post(cancel_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page.status_code).to eq(404) }
end
end
describe "POST /:project/jobs/:id/retry" do
context "Job from project" do
context "Job from project", :js do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel'
page.within('.build-header') do
click_link 'Retry job'
end
find('.js-cancel-job').click()
find('.js-retry-button').trigger('click')
end
it 'shows the right status and buttons' do
it 'shows the right status and buttons', :js do
expect(page).to have_http_status(200)
expect(page).to have_content 'pending'
page.within('aside.right-sidebar') do
expect(page).to have_content 'Cancel'
end
end
end
context "Job from other project" do
before do
build.run!
visit namespace_project_job_path(project.namespace, project, build)
click_link 'Cancel'
page.driver.post(retry_namespace_project_job_path(project.namespace, project, build2))
end
it { expect(page).to have_http_status(404) }
end
context "Job that current user is not allowed to retry" do
before do
build.run!
@ -435,20 +459,17 @@ feature 'Jobs', :feature do
Capybara.current_session.driver.headers = { 'X-Sendfile-Type' => 'X-Sendfile' }
build.run!
allow_any_instance_of(Gitlab::Ci::Trace).to receive(:paths)
.and_return(paths)
visit namespace_project_job_path(project.namespace, project, build)
end
context 'when build has trace in file', :js do
let(:paths) do
[existing_file]
end
before do
find('.js-raw-link-controller').click()
allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([existing_file])
visit namespace_project_job_path(namespace, project, build)
find('.js-raw-link-controller').click
end
it 'sends the right headers' do
@ -458,11 +479,17 @@ feature 'Jobs', :feature do
end
end
context 'when job has trace in DB' do
let(:paths) { [] }
context 'when job has trace in the database', :js do
before do
allow_any_instance_of(Gitlab::Ci::Trace)
.to receive(:paths)
.and_return([])
visit namespace_project_job_path(namespace, project, build)
end
it 'sends the right headers' do
expect(page.status_code).not_to have_selector('.js-raw-link-controller')
expect(page).not_to have_selector('.js-raw-link-controller')
end
end
end

View file

@ -132,23 +132,6 @@ describe('Build', () => {
expect($('#build-trace .js-build-output').text()).not.toMatch(/Update/);
expect($('#build-trace .js-build-output').text()).toMatch(/Different/);
});
it('reloads the page when the build is done', () => {
spyOn(gl.utils, 'visitUrl');
const deferred = $.Deferred();
spyOn($, 'ajax').and.returnValue(deferred.promise());
deferred.resolve({
html: '<span>Final</span>',
status: 'passed',
append: true,
complete: true,
});
this.build = new Build();
expect(gl.utils.visitUrl).toHaveBeenCalledWith(BUILD_URL);
});
});
describe('truncated information', () => {

View file

@ -1,4 +1,4 @@
import '~/lib/utils/datetime_utility';
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
(() => {
describe('Date time utils', () => {
@ -82,4 +82,13 @@ import '~/lib/utils/datetime_utility';
});
});
});
describe('timeIntervalInWords', () => {
it('should return string with number of minutes and seconds', () => {
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(timeIntervalInWords(1)).toEqual('1 second');
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
});
});
})();

View file

@ -0,0 +1,63 @@
import Vue from 'vue';
import headerComponent from '~/jobs/components/header.vue';
describe('Job details header', () => {
let HeaderComponent;
let vm;
let props;
beforeEach(() => {
HeaderComponent = Vue.extend(headerComponent);
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
props = {
job: {
status: {
group: 'failed',
icon: 'ci-status-failed',
label: 'failed',
text: 'failed',
details_path: 'path',
},
id: 123,
created_at: threeWeeksAgo.toISOString(),
user: {
web_url: 'path',
name: 'Foo',
username: 'foobar',
email: 'foo@bar.com',
avatar_url: 'link',
},
retry_path: 'path',
new_issue_path: 'path',
},
isLoading: false,
};
vm = new HeaderComponent({ propsData: props }).$mount();
});
afterEach(() => {
vm.$destroy();
});
it('should render provided job information', () => {
expect(
vm.$el.querySelector('.header-main-content').textContent.replace(/\s+/g, ' ').trim(),
).toEqual('failed Job #123 triggered 3 weeks ago by Foo');
});
it('should render retry link', () => {
expect(
vm.$el.querySelector('.js-retry-button').getAttribute('href'),
).toEqual(props.job.retry_path);
});
it('should render new issue link', () => {
expect(
vm.$el.querySelector('.js-new-issue').getAttribute('href'),
).toEqual(props.job.new_issue_path);
});
});

View file

@ -0,0 +1,43 @@
import Vue from 'vue';
import JobMediator from '~/jobs/job_details_mediator';
import job from './mock_data';
describe('JobMediator', () => {
let mediator;
beforeEach(() => {
mediator = new JobMediator({ endpoint: 'foo' });
});
it('should set defaults', () => {
expect(mediator.store).toBeDefined();
expect(mediator.service).toBeDefined();
expect(mediator.options).toEqual({ endpoint: 'foo' });
expect(mediator.state.isLoading).toEqual(false);
});
describe('request and store data', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(job), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptor, interceptor);
});
it('should store received data', (done) => {
mediator.fetchJob();
setTimeout(() => {
expect(mediator.store.state.job).toEqual(job);
done();
}, 0);
});
});
});

View file

@ -0,0 +1,26 @@
import JobStore from '~/jobs/stores/job_store';
import job from './mock_data';
describe('Job Store', () => {
let store;
beforeEach(() => {
store = new JobStore();
});
it('should set defaults', () => {
expect(store.state.job).toEqual({});
});
describe('storeJob', () => {
it('should store empty object if none is provided', () => {
store.storeJob();
expect(store.state.job).toEqual({});
});
it('should store provided argument', () => {
store.storeJob(job);
expect(store.state.job).toEqual(job);
});
});
});

View file

@ -0,0 +1,123 @@
const threeWeeksAgo = new Date();
threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21);
export default {
id: 4757,
name: 'test',
build_path: '/root/ci-mock/-/jobs/4757',
retry_path: '/root/ci-mock/-/jobs/4757/retry',
cancel_path: '/root/ci-mock/-/jobs/4757/cancel',
new_issue_path: '/root/ci-mock/issues/new',
playable: false,
created_at: threeWeeksAgo.toISOString(),
updated_at: threeWeeksAgo.toISOString(),
finished_at: threeWeeksAgo.toISOString(),
queued: 9.54,
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/-/jobs/4757',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
action: {
icon: 'icon_action_retry',
title: 'Retry',
path: '/root/ci-mock/-/jobs/4757/retry',
method: 'post',
},
},
coverage: 20,
erased_at: threeWeeksAgo.toISOString(),
duration: 6.785563,
tags: ['tag'],
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
erase_path: '/root/ci-mock/-/jobs/4757/erase',
artifacts: [null],
runner: {
id: 1,
description: 'local ci runner',
edit_path: '/root/ci-mock/runners/1/edit',
},
pipeline: {
id: 140,
user: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
active: false,
coverage: null,
source: 'unknown',
created_at: '2017-05-24T09:59:58.634Z',
updated_at: '2017-06-01T17:32:00.062Z',
path: '/root/ci-mock/pipelines/140',
flags: {
latest: true,
stuck: false,
yaml_errors: false,
retryable: false,
cancelable: false,
},
details: {
status: {
icon: 'icon_status_success',
text: 'passed',
label: 'passed',
group: 'success',
has_details: true,
details_path: '/root/ci-mock/pipelines/140',
favicon: '/assets/ci_favicons/dev/favicon_status_success-308b4fc054cdd1b68d0865e6cfb7b02e92e3472f201507418f8eddb74ac11a59.ico',
},
duration: 6,
finished_at: '2017-06-01T17:32:00.042Z',
},
ref: {
name: 'abc',
path: '/root/ci-mock/commits/abc',
tag: false,
branch: true,
},
commit: {
id: 'c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
short_id: 'c5864777',
title: 'Add new file',
created_at: '2017-05-24T10:59:52.000+01:00',
parent_ids: ['798e5f902592192afaba73f4668ae30e56eae492'],
message: 'Add new file',
author_name: 'Root',
author_email: 'admin@example.com',
authored_date: '2017-05-24T10:59:52.000+01:00',
committer_name: 'Root',
committer_email: 'admin@example.com',
committed_date: '2017-05-24T10:59:52.000+01:00',
author: {
name: 'Root',
username: 'root',
id: 1,
state: 'active',
avatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
web_url: 'http://localhost:3000/root',
},
author_gravatar_url: 'http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
commit_url: 'http://localhost:3000/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
commit_path: '/root/ci-mock/commit/c58647773a6b5faf066d4ad6ff2c9fbba5f180f6',
},
},
merge_request: {
iid: 2,
path: '/root/ci-mock/merge_requests/2',
},
raw_path: '/root/ci-mock/builds/4757/raw',
};

View file

@ -0,0 +1,40 @@
import Vue from 'vue';
import sidebarDetailRow from '~/jobs/components/sidebar_detail_row.vue';
describe('Sidebar detail row', () => {
let SidebarDetailRow;
let vm;
beforeEach(() => {
SidebarDetailRow = Vue.extend(sidebarDetailRow);
});
afterEach(() => {
vm.$destroy();
});
it('should render no title', () => {
vm = new SidebarDetailRow({
propsData: {
value: 'this is the value',
},
}).$mount();
expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toEqual('this is the value');
});
beforeEach(() => {
vm = new SidebarDetailRow({
propsData: {
title: 'this is the title',
value: 'this is the value',
},
}).$mount();
});
it('should render provided title and value', () => {
expect(
vm.$el.textContent.replace(/\s+/g, ' ').trim(),
).toEqual('this is the title: this is the value');
});
});

View file

@ -0,0 +1,111 @@
import Vue from 'vue';
import sidebarDetailsBlock from '~/jobs/components/sidebar_details_block.vue';
import job from './mock_data';
describe('Sidebar details block', () => {
let SidebarComponent;
let vm;
function trimWhitespace(element) {
return element.textContent.replace(/\s+/g, ' ').trim();
}
beforeEach(() => {
SidebarComponent = Vue.extend(sidebarDetailsBlock);
});
afterEach(() => {
vm.$destroy();
});
describe('when it is loading', () => {
it('should render a loading spinner', () => {
vm = new SidebarComponent({
propsData: {
job: {},
isLoading: true,
},
}).$mount();
expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
});
});
beforeEach(() => {
vm = new SidebarComponent({
propsData: {
job,
isLoading: false,
},
}).$mount();
});
describe('actions', () => {
it('should render link to new issue', () => {
expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual(job.new_issue_path);
expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue');
});
it('should render link to retry job', () => {
expect(vm.$el.querySelector('.js-retry-job').getAttribute('href')).toEqual(job.retry_path);
});
it('should render link to cancel job', () => {
expect(vm.$el.querySelector('.js-cancel-job').getAttribute('href')).toEqual(job.cancel_path);
});
});
describe('information', () => {
it('should render merge request link', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-mr')),
).toEqual('Merge Request: !2');
expect(
vm.$el.querySelector('.js-job-mr a').getAttribute('href'),
).toEqual(job.merge_request.path);
});
it('should render job duration', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-duration')),
).toEqual('Duration: 6 seconds');
});
it('should render erased date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-erased')),
).toEqual('Erased: 3 weeks ago');
});
it('should render finished date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-finished')),
).toEqual('Finished: 3 weeks ago');
});
it('should render queued date', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-queued')),
).toEqual('Queued: 9 seconds');
});
it('should render runner ID', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-runner')),
).toEqual('Runner: #1');
});
it('should render coverage', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-coverage')),
).toEqual('Coverage: 20%');
});
it('should render tags', () => {
expect(
trimWhitespace(vm.$el.querySelector('.js-job-tags')),
).toEqual('Tags: tag');
});
});
});

View file

@ -43,6 +43,7 @@ describe('Header CI Component', () => {
isLoading: false,
},
],
hasSidebarButton: true,
};
vm = new HeaderCi({
@ -90,4 +91,8 @@ describe('Header CI Component', () => {
done();
});
});
it('should render sidebar toggle button', () => {
expect(vm.$el.querySelector('.js-sidebar-build-toggle')).toBeDefined();
});
});

View file

@ -2,12 +2,13 @@ require 'spec_helper'
describe BuildEntity do
let(:user) { create(:user) }
let(:build) { create(:ci_build, :failed) }
let(:build) { create(:ci_build) }
let(:project) { build.project }
let(:request) { double('request') }
before do
allow(request).to receive(:current_user).and_return(user)
project.add_developer(user)
end
let(:entity) do
@ -16,9 +17,8 @@ describe BuildEntity do
subject { entity.as_json }
it 'contains paths to build page and retry action' do
expect(subject).to include(:build_path, :retry_path)
expect(subject[:retry_path]).not_to be_nil
it 'contains paths to build page action' do
expect(subject).to include(:build_path)
end
it 'does not contain sensitive information' do
@ -39,12 +39,32 @@ describe BuildEntity do
expect(subject[:status]).to include :icon, :favicon, :text, :label
end
context 'when build is a regular job' do
context 'when build is retryable' do
before do
build.update(status: :failed)
end
it 'contains cancel path' do
expect(subject).to include(:retry_path)
end
end
context 'when build is cancelable' do
before do
build.update(status: :running)
end
it 'contains cancel path' do
expect(subject).to include(:cancel_path)
end
end
context 'when build is a regular build' do
it 'does not contain path to play action' do
expect(subject).not_to include(:play_path)
end
it 'is not a playable job' do
it 'is not a playable build' do
expect(subject[:playable]).to be false
end
end

View file

@ -15,36 +15,6 @@ describe 'projects/jobs/show', :view do
allow(view).to receive(:can?).and_return(true)
end
describe 'job information in header' do
let(:build) do
create(:ci_build, :success, environment: 'staging')
end
before do
render
end
it 'shows status name' do
expect(rendered).to have_css('.ci-status.ci-success', text: 'passed')
end
it 'does not render a link to the job' do
expect(rendered).not_to have_link('passed')
end
it 'shows job id' do
expect(rendered).to have_css('.js-build-id', text: build.id)
end
it 'shows a link to the pipeline' do
expect(rendered).to have_link(build.pipeline.id)
end
it 'shows a link to the commit' do
expect(rendered).to have_link(build.pipeline.short_sha)
end
end
describe 'environment info in job view' do
context 'job with latest deployment' do
let(:build) do
@ -215,34 +185,6 @@ describe 'projects/jobs/show', :view do
end
end
context 'when job is not running' do
before do
build.success!
render
end
it 'shows retry button' do
expect(rendered).to have_link('Retry')
end
context 'if build passed' do
it 'does not show New issue button' do
expect(rendered).not_to have_link('New issue')
end
end
context 'if build failed' do
before do
build.status = 'failed'
render
end
it 'shows New issue button' do
expect(rendered).to have_link('New issue')
end
end
end
describe 'commit title in sidebar' do
let(:commit_title) { project.commit.title }
@ -269,25 +211,4 @@ describe 'projects/jobs/show', :view do
expect(rendered).to have_css('.js-build-value', visible: false, text: 'TRIGGER_VALUE_2')
end
end
describe 'New issue button' do
before do
build.status = 'failed'
render
end
it 'links to issues/new with the title and description filled in' do
title = "Build Failed ##{build.id}"
build_url = namespace_project_job_url(project.namespace, project, build)
href = new_namespace_project_issue_path(
project.namespace,
project,
issue: {
title: title,
description: build_url
}
)
expect(rendered).to have_link('New issue', href: href)
end
end
end