Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
Jérome Perrin
gitlab-ce
Commits
452202e3
Commit
452202e3
authored
Jun 12, 2017
by
Filipa Lacerda
Committed by
Phil Hughes
Jun 12, 2017
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Improve Job detail view to make it refreshed in real-time instead of reloading
parent
d25f6fcf
Changes
36
Show whitespace changes
Inline
Side-by-side
Showing
36 changed files
with
1171 additions
and
313 deletions
+1171
-313
app/assets/javascripts/build.js
app/assets/javascripts/build.js
+25
-6
app/assets/javascripts/dispatcher.js
app/assets/javascripts/dispatcher.js
+0
-4
app/assets/javascripts/jobs/components/header.vue
app/assets/javascripts/jobs/components/header.vue
+83
-0
app/assets/javascripts/jobs/components/sidebar_detail_row.vue
...assets/javascripts/jobs/components/sidebar_detail_row.vue
+31
-0
app/assets/javascripts/jobs/components/sidebar_details_block.vue
...ets/javascripts/jobs/components/sidebar_details_block.vue
+150
-0
app/assets/javascripts/jobs/job_details_bundle.js
app/assets/javascripts/jobs/job_details_bundle.js
+68
-0
app/assets/javascripts/jobs/job_details_mediator.js
app/assets/javascripts/jobs/job_details_mediator.js
+67
-0
app/assets/javascripts/jobs/services/job_service.js
app/assets/javascripts/jobs/services/job_service.js
+14
-0
app/assets/javascripts/jobs/stores/job_store.js
app/assets/javascripts/jobs/stores/job_store.js
+11
-0
app/assets/javascripts/lib/utils/datetime_utility.js
app/assets/javascripts/lib/utils/datetime_utility.js
+21
-0
app/assets/javascripts/pipelines/components/header_component.vue
...ets/javascripts/pipelines/components/header_component.vue
+1
-1
app/assets/javascripts/vue_shared/components/header_ci_component.vue
...javascripts/vue_shared/components/header_ci_component.vue
+29
-3
app/assets/stylesheets/pages/builds.scss
app/assets/stylesheets/pages/builds.scss
+50
-53
app/assets/stylesheets/pages/pipelines.scss
app/assets/stylesheets/pages/pipelines.scss
+8
-1
app/models/commit_status.rb
app/models/commit_status.rb
+5
-0
app/serializers/build_details_entity.rb
app/serializers/build_details_entity.rb
+2
-4
app/serializers/build_entity.rb
app/serializers/build_entity.rb
+13
-1
app/views/projects/jobs/_sidebar.html.haml
app/views/projects/jobs/_sidebar.html.haml
+24
-56
app/views/projects/jobs/show.html.haml
app/views/projects/jobs/show.html.haml
+40
-36
changelogs/unreleased/31397-job-detail-real-time.yml
changelogs/unreleased/31397-job-detail-real-time.yml
+4
-0
config/webpack.config.js
config/webpack.config.js
+2
-0
features/project/builds/permissions.feature
features/project/builds/permissions.feature
+1
-0
features/project/builds/summary.feature
features/project/builds/summary.feature
+3
-0
features/steps/project/builds/summary.rb
features/steps/project/builds/summary.rb
+1
-1
spec/features/projects/jobs_spec.rb
spec/features/projects/jobs_spec.rb
+71
-44
spec/javascripts/build_spec.js
spec/javascripts/build_spec.js
+0
-17
spec/javascripts/datetime_utility_spec.js
spec/javascripts/datetime_utility_spec.js
+10
-1
spec/javascripts/jobs/header_spec.js
spec/javascripts/jobs/header_spec.js
+63
-0
spec/javascripts/jobs/job_details_mediator_spec.js
spec/javascripts/jobs/job_details_mediator_spec.js
+43
-0
spec/javascripts/jobs/job_store_spec.js
spec/javascripts/jobs/job_store_spec.js
+26
-0
spec/javascripts/jobs/mock_data.js
spec/javascripts/jobs/mock_data.js
+123
-0
spec/javascripts/jobs/sidebar_detail_row_spec.js
spec/javascripts/jobs/sidebar_detail_row_spec.js
+40
-0
spec/javascripts/jobs/sidebar_details_block_spec.js
spec/javascripts/jobs/sidebar_details_block_spec.js
+111
-0
spec/javascripts/vue_shared/components/header_ci_component_spec.js
...scripts/vue_shared/components/header_ci_component_spec.js
+5
-0
spec/serializers/build_entity_spec.rb
spec/serializers/build_entity_spec.rb
+26
-6
spec/views/projects/jobs/show.html.haml_spec.rb
spec/views/projects/jobs/show.html.haml_spec.rb
+0
-79
No files found.
app/assets/javascripts/build.js
View file @
452202e3
...
...
@@ -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
()
{
...
...
app/assets/javascripts/dispatcher.js
View file @
452202e3
...
...
@@ -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
'
))
{
...
...
app/assets/javascripts/jobs/components/header.vue
0 → 100644
View file @
452202e3
<
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
>
app/assets/javascripts/jobs/components/sidebar_detail_row.vue
0 → 100644
View file @
452202e3
<
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
>
app/assets/javascripts/jobs/components/sidebar_details_block.vue
0 → 100644
View file @
452202e3
<
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>
app/assets/javascripts/jobs/job_details_bundle.js
0 → 100644
View file @
452202e3
/* 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
,
},
});
},
});
});
app/assets/javascripts/jobs/job_details_mediator.js
0 → 100644
View file @
452202e3
/* 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.
'
);
}
}
app/assets/javascripts/jobs/services/job_service.js
0 → 100644
View file @
452202e3
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
();
}
}
app/assets/javascripts/jobs/stores/job_store.js
0 → 100644
View file @
452202e3
export
default
class
JobStore
{
constructor
()
{
this
.
state
=
{
job
:
{},
};
}
storeJob
(
job
=
{})
{
this
.
state
.
job
=
job
;
}
}
app/assets/javascripts/lib/utils/datetime_utility.js
View file @
452202e3
...
...
@@ -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
;
}
app/assets/javascripts/pipelines/components/header_component.vue
View file @
452202e3
...
...
@@ -91,7 +91,7 @@ export default {
@
actionClicked=
"postAction"
/>
<loading-icon
v-
else
v-
if=
"isLoading"
size=
"2"
/>
</div>
</
template
>
app/assets/javascripts/vue_shared/components/header_ci_component.vue
View file @
452202e3
...
...
@@ -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-control
s"
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>
app/assets/stylesheets/pages/builds.scss
View file @
452202e3
...
...
@@ -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
:
1
px
;
top
:
5
px
;
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
;
.btn-inverted-secondary
{
color
:
$blue-500
;
&
:hover
{
text-decoration
:
underline
;
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
{
...
...
app/assets/stylesheets/pages/pipelines.scss
View file @
452202e3
...
...
@@ -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
;
}
}
}
app/models/commit_status.rb
View file @
452202e3
...
...
@@ -132,6 +132,11 @@ class CommitStatus < ActiveRecord::Base
false
end
# To be overriden when inherrited from
def
cancelable?
false
end
def
stuck?
false
end
...
...
app/serializers/build_details_entity.rb
View file @
452202e3
...
...
@@ -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
...
...
app/serializers/build_entity.rb
View file @
452202e3
...
...
@@ -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
...
...
app/views/projects/jobs/_sidebar.html.haml
View file @
452202e3
-
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:
"#"
}
.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'
)
-
if
@build
.
coverage
.block.coverage
.title
Test coverage
%p
.build-detail-row
#{
@build
.
coverage
}
%
.blocks-container
#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'
)
...
...
app/views/projects/jobs/show.html.haml
View file @
452202e3
...
...
@@ -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,15 +46,14 @@
-
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?
.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
<
...
...
@@ -91,3 +89,9 @@
=
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'
)
changelogs/unreleased/31397-job-detail-real-time.yml
0 → 100644
View file @
452202e3
---
title
:
Adds realtime feature to job show view header and sidebar info. Updates UX.
merge_request
:
author
:
config/webpack.config.js
View file @
452202e3
...
...
@@ -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
'
,
...
...
features/project/builds/permissions.feature
View file @
452202e3
...
...
@@ -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
...
...
features/project/builds/summary.feature
View file @
452202e3
...
...
@@ -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
...
...
features/steps/project/builds/summary.rb
View file @
452202e3
...
...
@@ -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
e
q
ci_lint_path
expect
(
ci_lint_tool_link
[
:href
]).
to
e
nd_with
(
ci_lint_path
)
end
end
...
...
spec/features/projects/jobs_spec.rb
View file @
452202e3
...
...
@@ -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
...
...
spec/javascripts/build_spec.js
View file @
452202e3
...
...
@@ -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
'
,
()
=>
{
...
...
spec/javascripts/datetime_utility_spec.js
View file @
452202e3
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
'
);
});
});
})();
spec/javascripts/jobs/header_spec.js
0 → 100644
View file @
452202e3
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
);
});
});
spec/javascripts/jobs/job_details_mediator_spec.js
0 → 100644
View file @
452202e3
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
);
});
});
});
spec/javascripts/jobs/job_store_spec.js
0 → 100644
View file @
452202e3
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
);
});
});
});
spec/javascripts/jobs/mock_data.js
0 → 100644
View file @
452202e3
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
'
,
};
spec/javascripts/jobs/sidebar_detail_row_spec.js
0 → 100644
View file @
452202e3
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
'
);
});
});
spec/javascripts/jobs/sidebar_details_block_spec.js
0 → 100644
View file @
452202e3
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
'
);
});
});
});
spec/javascripts/vue_shared/components/header_ci_component_spec.js
View file @
452202e3
...
...
@@ -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
();
});
});
spec/serializers/build_entity_spec.rb
View file @
452202e3
...
...
@@ -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
...
...
spec/views/projects/jobs/show.html.haml_spec.rb
View file @
452202e3
...
...
@@ -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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment