Skip to content
Snippets Groups Projects
Commit 8cd578a7 authored by Kamil Trzcińśki's avatar Kamil Trzcińśki
Browse files

Merge branch '25226-realtime-pipelines-fe' into 'master'

Rewrite Pipeline Graph in Vue.js to allow realtime

Closes #25226 and #31557

See merge request !10878
parents 6ad3814e e0e52fe5
No related branches found
No related tags found
No related merge requests found
Showing
with 779 additions and 166 deletions
import CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
import CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
import FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
import MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
import PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
import RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
import SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
import SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
import WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
const StatusIconEntityMap = {
icon_status_canceled: CANCELED_SVG,
icon_status_created: CREATED_SVG,
icon_status_failed: FAILED_SVG,
icon_status_manual: MANUAL_SVG,
icon_status_pending: PENDING_SVG,
icon_status_running: RUNNING_SVG,
icon_status_skipped: SKIPPED_SVG,
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export {
CANCELED_SVG,
CREATED_SVG,
FAILED_SVG,
MANUAL_SVG,
PENDING_SVG,
RUNNING_SVG,
SKIPPED_SVG,
SUCCESS_SVG,
WARNING_SVG,
StatusIconEntityMap as default,
};
Loading
Loading
@@ -49,6 +49,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki';
import Pipelines from './pipelines';
import BlobViewer from './blob/viewer/index';
import AutoWidthDropdownSelect from './issuable/auto_width_dropdown_select';
 
Loading
Loading
@@ -257,7 +258,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
const { controllerAction } = document.querySelector('.js-pipeline-container').dataset;
const pipelineStatusUrl = `${document.querySelector('.js-pipeline-tab-link a').getAttribute('href')}/status.json`;
 
new gl.Pipelines({
new Pipelines({
initTabs: true,
pipelineStatusUrl,
tabsOptions: {
Loading
Loading
Loading
Loading
@@ -31,82 +31,78 @@
*
* ### How to use
*
* new window.gl.LinkedTabs({
* new LinkedTabs({
* action: "#{controller.action_name}",
* defaultAction: 'tab1',
* parentEl: '.tab-links'
* });
*/
 
(() => {
window.gl = window.gl || {};
export default class LinkedTabs {
/**
* Binds the events and activates de default tab.
*
* @param {Object} options
*/
constructor(options = {}) {
this.options = options;
 
window.gl.LinkedTabs = class LinkedTabs {
/**
* Binds the events and activates de default tab.
*
* @param {Object} options
*/
constructor(options) {
this.options = options || {};
this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction;
 
this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction;
if (this.action === 'show') {
this.action = this.defaultAction;
}
if (this.action === 'show') {
this.action = this.defaultAction;
}
 
this.currentLocation = window.location;
this.currentLocation = window.location;
 
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
 
// since this is a custom event we need jQuery :(
$(document)
.off('shown.bs.tab', tabSelector)
.on('shown.bs.tab', tabSelector, e => this.tabShown(e));
// since this is a custom event we need jQuery :(
$(document)
.off('shown.bs.tab', tabSelector)
.on('shown.bs.tab', tabSelector, e => this.tabShown(e));
 
this.activateTab(this.action);
}
this.activateTab(this.action);
}
 
/**
* Handles the `shown.bs.tab` event to set the currect url action.
*
* @param {type} evt
* @return {Function}
*/
tabShown(evt) {
const source = evt.target.getAttribute('href');
/**
* Handles the `shown.bs.tab` event to set the currect url action.
*
* @param {type} evt
* @return {Function}
*/
tabShown(evt) {
const source = evt.target.getAttribute('href');
 
return this.setCurrentAction(source);
}
return this.setCurrentAction(source);
}
 
/**
* Updates the URL with the path that matched the given action.
*
* @param {String} source
* @return {String}
*/
setCurrentAction(source) {
const copySource = source;
/**
* Updates the URL with the path that matched the given action.
*
* @param {String} source
* @return {String}
*/
setCurrentAction(source) {
const copySource = source;
 
copySource.replace(/\/+$/, '');
copySource.replace(/\/+$/, '');
 
const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
 
history.replaceState({
url: newState,
}, document.title, newState);
return newState;
}
history.replaceState({
url: newState,
}, document.title, newState);
return newState;
}
 
/**
* Given the current action activates the correct tab.
* http://getbootstrap.com/javascript/#tab-show
* Note: Will trigger `shown.bs.tab`
*/
activateTab() {
return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
}
};
})();
/**
* Given the current action activates the correct tab.
* http://getbootstrap.com/javascript/#tab-show
* Note: Will trigger `shown.bs.tab`
*/
activateTab() {
return $(`${this.options.parentEl} a[data-action='${this.action}']`).tab('show');
}
}
/* eslint-disable no-new, guard-for-in, no-restricted-syntax, no-continue, no-param-reassign, max-len */
import LinkedTabs from './lib/utils/bootstrap_linked_tabs';
 
require('./lib/utils/bootstrap_linked_tabs');
((global) => {
class Pipelines {
constructor(options = {}) {
if (options.initTabs && options.tabsOptions) {
new global.LinkedTabs(options.tabsOptions);
}
if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
this.addMarginToBuildColumns();
export default class Pipelines {
constructor(options = {}) {
if (options.initTabs && options.tabsOptions) {
// eslint-disable-next-line no-new
new LinkedTabs(options.tabsOptions);
}
 
addMarginToBuildColumns() {
this.pipelineGraph = document.querySelector('.js-pipeline-graph');
const secondChildBuildNodes = this.pipelineGraph.querySelectorAll('.build:nth-child(2)');
for (const buildNodeIndex in secondChildBuildNodes) {
const buildNode = secondChildBuildNodes[buildNodeIndex];
const firstChildBuildNode = buildNode.previousElementSibling;
if (!firstChildBuildNode || !firstChildBuildNode.matches('.build')) continue;
const multiBuildColumn = buildNode.closest('.stage-column');
const previousColumn = multiBuildColumn.previousElementSibling;
if (!previousColumn || !previousColumn.matches('.stage-column')) continue;
multiBuildColumn.classList.add('left-margin');
firstChildBuildNode.classList.add('left-connector');
const columnBuilds = previousColumn.querySelectorAll('.build');
if (columnBuilds.length === 1) previousColumn.classList.add('no-margin');
}
this.pipelineGraph.classList.remove('hidden');
if (options.pipelineStatusUrl) {
gl.utils.setCiStatusFavicon(options.pipelineStatusUrl);
}
}
global.Pipelines = Pipelines;
})(window.gl || (window.gl = {}));
}
<script>
import getActionIcon from '../../../vue_shared/ci_action_icons';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
props: {
tooltipText: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
actionMethod: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
},
mixins: [
tooltipMixin,
],
computed: {
actionIconSvg() {
return getActionIcon(this.actionIcon);
},
cssClass() {
return `js-${gl.text.dasherize(this.actionIcon)}`;
},
},
};
</script>
<template>
<a
:data-method="actionMethod"
:title="tooltipText"
:href="link"
ref="tooltip"
class="ci-action-icon-container"
data-toggle="tooltip"
data-container="body">
<i
class="ci-action-icon-wrapper"
:class="cssClass"
v-html="actionIconSvg"
aria-hidden="true"
/>
</a>
</template>
<script>
import getActionIcon from '../../../vue_shared/ci_action_icons';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders either a cancel, retry or play icon pointing to the given path.
* TODO: Remove UJS from here and use an async request instead.
*/
export default {
props: {
tooltipText: {
type: String,
required: true,
},
link: {
type: String,
required: true,
},
actionMethod: {
type: String,
required: true,
},
actionIcon: {
type: String,
required: true,
},
},
mixins: [
tooltipMixin,
],
computed: {
actionIconSvg() {
return getActionIcon(this.actionIcon);
},
},
};
</script>
<template>
<a
:data-method="actionMethod"
:title="tooltipText"
:href="link"
ref="tooltip"
rel="nofollow"
class="ci-action-icon-wrapper js-ci-status-icon"
data-toggle="tooltip"
data-container="body"
v-html="actionIconSvg"
aria-label="Job's action">
</a>
</template>
<script>
import jobNameComponent from './job_name_component.vue';
import jobComponent from './job_component.vue';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders the dropdown for the pipeline graph.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "icon_action_retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
props: {
job: {
type: Object,
required: true,
},
},
mixins: [
tooltipMixin,
],
components: {
jobComponent,
jobNameComponent,
},
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
},
};
</script>
<template>
<div>
<button
type="button"
data-toggle="dropdown"
data-container="body"
class="dropdown-menu-toggle build-content"
:title="tooltipText"
ref="tooltip">
<job-name-component
:name="job.name"
:status="job.status" />
<span class="dropdown-counter-badge">
{{job.size}}
</span>
</button>
<ul class="dropdown-menu big-pipeline-graph-dropdown-menu js-grouped-pipeline-dropdown">
<li class="scrollable-menu">
<ul>
<li v-for="item in job.jobs">
<job-component
:job="item"
:is-dropdown="true"
css-class-job-name="mini-pipeline-graph-dropdown-item"
/>
</li>
</ul>
</li>
</ul>
</div>
</template>
<script>
/* global Flash */
import Visibility from 'visibilityjs';
import Poll from '../../../lib/utils/poll';
import PipelineService from '../../services/pipeline_service';
import PipelineStore from '../../stores/pipeline_store';
import stageColumnComponent from './stage_column_component.vue';
import '../../../flash';
export default {
components: {
stageColumnComponent,
},
data() {
const DOMdata = document.getElementById('js-pipeline-graph-vue').dataset;
const store = new PipelineStore();
return {
isLoading: false,
endpoint: DOMdata.endpoint,
store,
state: store.state,
};
},
created() {
this.service = new PipelineService(this.endpoint);
const poll = new Poll({
resource: this.service,
method: 'getPipeline',
successCallback: this.successCallback,
errorCallback: this.errorCallback,
});
if (!Visibility.hidden()) {
this.isLoading = true;
poll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
poll.restart();
} else {
poll.stop();
}
});
},
methods: {
successCallback(response) {
const data = response.json();
this.isLoading = false;
this.store.storeGraph(data.details.stages);
},
errorCallback() {
this.isLoading = false;
return new Flash('An error occurred while fetching the pipeline.');
},
capitalizeStageName(name) {
return name.charAt(0).toUpperCase() + name.slice(1);
},
},
};
</script>
<template>
<div class="build-content middle-block js-pipeline-graph">
<div class="pipeline-visualization pipeline-graph">
<div class="text-center">
<i
v-if="isLoading"
class="loading-icon fa fa-spin fa-spinner fa-3x"
aria-label="Loading"
aria-hidden="true" />
</div>
<ul
v-if="!isLoading"
class="stage-column-list">
<stage-column-component
v-for="stage in state.graph"
:title="capitalizeStageName(stage.name)"
:jobs="stage.groups"
:key="stage.name"/>
</ul>
</div>
</div>
</template>
<script>
import actionComponent from './action_component.vue';
import dropdownActionComponent from './dropdown_action_component.vue';
import jobNameComponent from './job_name_component.vue';
import tooltipMixin from '../../../vue_shared/mixins/tooltip';
/**
* Renders the badge for the pipeline graph and the job's dropdown.
*
* The following object should be provided as `job`:
*
* {
* "id": 4256,
* "name": "test",
* "status": {
* "icon": "icon_status_success",
* "text": "passed",
* "label": "passed",
* "group": "success",
* "details_path": "/root/ci-mock/builds/4256",
* "action": {
* "icon": "icon_action_retry",
* "title": "Retry",
* "path": "/root/ci-mock/builds/4256/retry",
* "method": "post"
* }
* }
* }
*/
export default {
props: {
job: {
type: Object,
required: true,
},
cssClassJobName: {
type: String,
required: false,
default: '',
},
isDropdown: {
type: Boolean,
required: false,
default: false,
},
},
components: {
actionComponent,
dropdownActionComponent,
jobNameComponent,
},
mixins: [
tooltipMixin,
],
computed: {
tooltipText() {
return `${this.job.name} - ${this.job.status.label}`;
},
/**
* Verifies if the provided job has an action path
*
* @return {Boolean}
*/
hasAction() {
return this.job.status && this.job.status.action && this.job.status.action.path;
},
},
};
</script>
<template>
<div>
<a
v-if="job.status.details_path"
:href="job.status.details_path"
:title="tooltipText"
:class="cssClassJobName"
ref="tooltip"
data-toggle="tooltip"
data-container="body">
<job-name-component
:name="job.name"
:status="job.status"
/>
</a>
<div
v-else
:title="tooltipText"
:class="cssClassJobName"
ref="tooltip"
data-toggle="tooltip"
data-container="body">
<job-name-component
:name="job.name"
:status="job.status"
/>
</div>
<action-component
v-if="hasAction && !isDropdown"
:tooltip-text="job.status.action.title"
:link="job.status.action.path"
:action-icon="job.status.action.icon"
:action-method="job.status.action.method"
/>
<dropdown-action-component
v-if="hasAction && isDropdown"
:tooltip-text="job.status.action.title"
:link="job.status.action.path"
:action-icon="job.status.action.icon"
:action-method="job.status.action.method"
/>
</div>
</template>
<script>
import ciIcon from '../../../vue_shared/components/ci_icon.vue';
/**
* Component that renders both the CI icon status and the job name.
* Used in
* - Badge component
* - Dropdown badge components
*/
export default {
props: {
name: {
type: String,
required: true,
},
status: {
type: Object,
required: true,
},
},
components: {
ciIcon,
},
};
</script>
<template>
<span>
<ci-icon
:status="status" />
<span class="ci-status-text">
{{name}}
</span>
</span>
</template>
<script>
import jobComponent from './job_component.vue';
import dropdownJobComponent from './dropdown_job_component.vue';
export default {
props: {
title: {
type: String,
required: true,
},
jobs: {
type: Array,
required: true,
},
},
components: {
jobComponent,
dropdownJobComponent,
},
methods: {
firstJob(list) {
return list[0];
},
jobId(job) {
return `ci-badge-${job.name}`;
},
},
};
</script>
<template>
<li class="stage-column">
<div class="stage-name">
{{title}}
</div>
<div class="builds-container">
<ul>
<li
v-for="job in jobs"
:key="job.id"
class="build"
:id="jobId(job)">
<div class="curve"></div>
<job-component
v-if="job.size === 1"
:job="job"
css-class-job-name="build-content"
/>
<dropdown-job-component
v-if="job.size > 1"
:job="job"
/>
</li>
</ul>
</div>
</li>
</template>
Loading
Loading
@@ -14,7 +14,7 @@
*/
 
/* global Flash */
import StatusIconEntityMap from '../../ci_status_icons';
import { borderlessStatusIconEntityMap } from '../../vue_shared/ci_status_icons';
 
export default {
props: {
Loading
Loading
@@ -113,7 +113,7 @@ export default {
},
 
svgIcon() {
return StatusIconEntityMap[this.stage.status.icon];
return borderlessStatusIconEntityMap[this.stage.status.icon];
},
},
};
Loading
Loading
import Vue from 'vue';
import pipelineGraph from './components/graph/graph_component.vue';
document.addEventListener('DOMContentLoaded', () => new Vue({
el: '#js-pipeline-graph-vue',
components: {
pipelineGraph,
},
render: createElement => createElement('pipeline-graph'),
}));
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class PipelineService {
constructor(endpoint) {
this.pipeline = Vue.resource(endpoint);
}
getPipeline() {
return this.pipeline.get();
}
}
export default class PipelineStore {
constructor() {
this.state = {};
this.state.graph = [];
}
storeGraph(graph = []) {
this.state.graph = graph;
}
}
import cancelSVG from 'icons/_icon_action_cancel.svg';
import retrySVG from 'icons/_icon_action_retry.svg';
import playSVG from 'icons/_icon_action_play.svg';
export default function getActionIcon(action) {
let icon;
switch (action) {
case 'icon_action_cancel':
icon = cancelSVG;
break;
case 'icon_action_retry':
icon = retrySVG;
break;
case 'icon_action_play':
icon = playSVG;
break;
default:
icon = '';
}
return icon;
}
import BORDERLESS_CANCELED_SVG from 'icons/_icon_status_canceled_borderless.svg';
import BORDERLESS_CREATED_SVG from 'icons/_icon_status_created_borderless.svg';
import BORDERLESS_FAILED_SVG from 'icons/_icon_status_failed_borderless.svg';
import BORDERLESS_MANUAL_SVG from 'icons/_icon_status_manual_borderless.svg';
import BORDERLESS_PENDING_SVG from 'icons/_icon_status_pending_borderless.svg';
import BORDERLESS_RUNNING_SVG from 'icons/_icon_status_running_borderless.svg';
import BORDERLESS_SKIPPED_SVG from 'icons/_icon_status_skipped_borderless.svg';
import BORDERLESS_SUCCESS_SVG from 'icons/_icon_status_success_borderless.svg';
import BORDERLESS_WARNING_SVG from 'icons/_icon_status_warning_borderless.svg';
import CANCELED_SVG from 'icons/_icon_status_canceled.svg';
import CREATED_SVG from 'icons/_icon_status_created.svg';
import FAILED_SVG from 'icons/_icon_status_failed.svg';
import MANUAL_SVG from 'icons/_icon_status_manual.svg';
import PENDING_SVG from 'icons/_icon_status_pending.svg';
import RUNNING_SVG from 'icons/_icon_status_running.svg';
import SKIPPED_SVG from 'icons/_icon_status_skipped.svg';
import SUCCESS_SVG from 'icons/_icon_status_success.svg';
import WARNING_SVG from 'icons/_icon_status_warning.svg';
export const borderlessStatusIconEntityMap = {
icon_status_canceled: BORDERLESS_CANCELED_SVG,
icon_status_created: BORDERLESS_CREATED_SVG,
icon_status_failed: BORDERLESS_FAILED_SVG,
icon_status_manual: BORDERLESS_MANUAL_SVG,
icon_status_pending: BORDERLESS_PENDING_SVG,
icon_status_running: BORDERLESS_RUNNING_SVG,
icon_status_skipped: BORDERLESS_SKIPPED_SVG,
icon_status_success: BORDERLESS_SUCCESS_SVG,
icon_status_warning: BORDERLESS_WARNING_SVG,
};
export const statusIconEntityMap = {
icon_status_canceled: CANCELED_SVG,
icon_status_created: CREATED_SVG,
icon_status_failed: FAILED_SVG,
icon_status_manual: MANUAL_SVG,
icon_status_pending: PENDING_SVG,
icon_status_running: RUNNING_SVG,
icon_status_skipped: SKIPPED_SVG,
icon_status_success: SUCCESS_SVG,
icon_status_warning: WARNING_SVG,
};
export const statusCssClasses = {
icon_status_canceled: 'canceled',
icon_status_created: 'created',
icon_status_failed: 'failed',
icon_status_manual: 'manual',
icon_status_pending: 'pending',
icon_status_running: 'running',
icon_status_skipped: 'skipped',
icon_status_success: 'success',
icon_status_warning: 'warning',
};
<script>
import { statusIconEntityMap, statusCssClasses } from '../../vue_shared/ci_status_icons';
export default {
props: {
status: {
type: Object,
required: true,
},
},
computed: {
statusIconSvg() {
return statusIconEntityMap[this.status.icon];
},
cssClass() {
const status = statusCssClasses[this.status.icon];
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
},
},
};
</script>
<template>
<span
:class="cssClass"
v-html="statusIconSvg">
</span>
</template>
export default {
mounted() {
$(this.$refs.tooltip).tooltip();
},
updated() {
$(this.$refs.tooltip).tooltip('fixTitle');
},
};
Loading
Loading
@@ -486,7 +486,7 @@
color: $gl-text-color-secondary;
 
// Action Icons in big pipeline-graph nodes
> .ci-action-icon-container .ci-action-icon-wrapper {
> div > .ci-action-icon-container .ci-action-icon-wrapper {
height: 30px;
width: 30px;
background: $white-light;
Loading
Loading
@@ -511,7 +511,7 @@
}
}
 
> .ci-action-icon-container {
> div > .ci-action-icon-container {
position: absolute;
right: 5px;
top: 5px;
Loading
Loading
@@ -541,7 +541,7 @@
}
}
 
> .build-content {
> div > .build-content {
display: inline-block;
padding: 8px 10px 9px;
width: 100%;
Loading
Loading
@@ -557,34 +557,6 @@
}
 
 
.arrow {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: 18px;
}
&::before {
left: -5px;
margin-top: -6px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
left: -4px;
margin-top: -9px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
}
// Connect first build in each stage with right horizontal line
&:first-child {
&::after {
Loading
Loading
@@ -859,7 +831,8 @@
border-radius: 3px;
 
// build name
.ci-build-text {
.ci-build-text,
.ci-status-text {
font-weight: 200;
overflow: hidden;
white-space: nowrap;
Loading
Loading
@@ -911,6 +884,38 @@
}
}
 
/**
* Top arrow in the dropdown in the big pipeline graph
*/
.big-pipeline-graph-dropdown-menu {
&::before,
&::after {
content: '';
display: inline-block;
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
top: 18px;
}
&::before {
left: -5px;
margin-top: -6px;
border-width: 7px 5px 7px 0;
border-right-color: $border-color;
}
&::after {
left: -4px;
margin-top: -9px;
border-width: 10px 7px 10px 0;
border-right-color: $white-light;
}
}
/**
* Top arrow in the dropdown in the mini pipeline graph
*/
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment