diff options
Diffstat (limited to 'app')
77 files changed, 1889 insertions, 311 deletions
diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 index e3241974e59..c345fb6ce14 100644 --- a/app/assets/javascripts/boards/boards_bundle.js.es6 +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -16,7 +16,7 @@ require('./components/board'); require('./components/board_sidebar'); require('./components/new_list_dropdown'); require('./components/modal/index'); -require('./vue_resource_interceptor'); +require('../vue_shared/vue_resource_interceptor'); $(() => { const $boardApp = document.getElementById('board-app'); diff --git a/app/assets/javascripts/boards/components/modal/filters.js.es6 b/app/assets/javascripts/boards/components/modal/filters.js.es6 new file mode 100644 index 00000000000..6de06811d94 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters.js.es6 @@ -0,0 +1,49 @@ +/* global Vue */ +const userFilter = require('./filters/user'); +const milestoneFilter = require('./filters/milestone'); +const labelFilter = require('./filters/label'); + +module.exports = Vue.extend({ + name: 'modal-filters', + props: { + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, + destroyed() { + gl.issueBoards.ModalStore.setDefaultFilter(); + }, + components: { + userFilter, + milestoneFilter, + labelFilter, + }, + template: ` + <div class="modal-filters"> + <user-filter + dropdown-class-name="dropdown-menu-author" + toggle-class-name="js-user-search js-author-search" + toggle-label="Author" + field-name="author_id" + :project-id="projectId"></user-filter> + <user-filter + dropdown-class-name="dropdown-menu-author" + toggle-class-name="js-assignee-search" + toggle-label="Assignee" + field-name="assignee_id" + :null-user="true" + :project-id="projectId"></user-filter> + <milestone-filter :milestone-path="milestonePath"></milestone-filter> + <label-filter :label-path="labelPath"></label-filter> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/label.js.es6 b/app/assets/javascripts/boards/components/modal/filters/label.js.es6 new file mode 100644 index 00000000000..4fc8f72a145 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/label.js.es6 @@ -0,0 +1,54 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global LabelsSelect */ +module.exports = Vue.extend({ + name: 'filter-label', + props: { + labelPath: { + type: String, + required: true, + }, + }, + mounted() { + new LabelsSelect(this.$refs.dropdown); + }, + template: ` + <div class="dropdown"> + <button + class="dropdown-menu-toggle js-label-select js-multiselect js-extra-options" + type="button" + data-toggle="dropdown" + data-show-any="true" + data-show-no="true" + :data-labels="labelPath" + ref="dropdown"> + <span class="dropdown-toggle-text"> + Label + </span> + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"> + <div class="dropdown-title"> + Filter by label + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i class="fa fa-times dropdown-menu-close-icon"></i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + placeholder="Search" + autocomplete="off" /> + <i class="fa fa-search dropdown-input-search"></i> + <i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> + </div> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 b/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 new file mode 100644 index 00000000000..d555599d300 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/milestone.js.es6 @@ -0,0 +1,55 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global MilestoneSelect */ +module.exports = Vue.extend({ + name: 'filter-milestone', + props: { + milestonePath: { + type: String, + required: true, + }, + }, + mounted() { + new MilestoneSelect(null, this.$refs.dropdown); + }, + template: ` + <div class="dropdown"> + <button + class="dropdown-menu-toggle js-milestone-select" + type="button" + data-toggle="dropdown" + data-show-any="true" + data-show-upcoming="true" + data-field-name="milestone_title" + :data-milestones="milestonePath" + ref="dropdown"> + <span class="dropdown-toggle-text"> + Milestone + </span> + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-milestone"> + <div class="dropdown-title"> + <span>Filter by milestone</span> + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i class="fa fa-times dropdown-menu-close-icon"></i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + placeholder="Search milestones" + autocomplete="off" /> + <i class="fa fa-search dropdown-input-search"></i> + <i role="button" class="fa fa-times dropdown-input-clear js-dropdown-input-clear"></i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> + </div> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/filters/user.js.es6 b/app/assets/javascripts/boards/components/modal/filters/user.js.es6 new file mode 100644 index 00000000000..8523028c29c --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/filters/user.js.es6 @@ -0,0 +1,96 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global UsersSelect */ +module.exports = Vue.extend({ + name: 'filter-user', + props: { + toggleClassName: { + type: String, + required: true, + }, + dropdownClassName: { + type: String, + required: false, + default: '', + }, + toggleLabel: { + type: String, + required: true, + }, + fieldName: { + type: String, + required: true, + }, + nullUser: { + type: Boolean, + required: false, + default: false, + }, + projectId: { + type: Number, + required: true, + }, + }, + mounted() { + new UsersSelect(null, this.$refs.dropdown); + }, + computed: { + currentUsername() { + return gon.current_username; + }, + dropdownTitle() { + return `Filter by ${this.toggleLabel.toLowerCase()}`; + }, + inputPlaceholder() { + return `Search ${this.toggleLabel.toLowerCase()}`; + }, + }, + template: ` + <div class="dropdown"> + <button + class="dropdown-menu-toggle js-user-search" + :class="toggleClassName" + type="button" + data-toggle="dropdown" + data-current-user="true" + :data-any-user="'Any ' + toggleLabel" + :data-null-user="nullUser" + :data-field-name="fieldName" + :data-project-id="projectId" + :data-first-user="currentUsername" + ref="dropdown"> + <span class="dropdown-toggle-text"> + {{ toggleLabel }} + </span> + <i class="fa fa-chevron-down"></i> + </button> + <div + class="dropdown-menu dropdown-select dropdown-menu-user dropdown-menu-selectable" + :class="dropdownClassName"> + <div class="dropdown-title"> + {{ dropdownTitle }} + <button + class="dropdown-title-button dropdown-menu-close" + aria-label="Close" + type="button"> + <i class="fa fa-times dropdown-menu-close-icon"></i> + </button> + </div> + <div class="dropdown-input"> + <input + type="search" + class="dropdown-input-field" + autocomplete="off" + :placeholder="inputPlaceholder" /> + <i class="fa fa-search dropdown-input-search"></i> + <i + role="button" + class="fa fa-times dropdown-input-clear js-dropdown-input-clear"> + </i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-loading"><i class="fa fa-spinner fa-spin"></i></div> + </div> + </div> + `, +}); diff --git a/app/assets/javascripts/boards/components/modal/header.js.es6 b/app/assets/javascripts/boards/components/modal/header.js.es6 index ab903722ba4..70c088f9054 100644 --- a/app/assets/javascripts/boards/components/modal/header.js.es6 +++ b/app/assets/javascripts/boards/components/modal/header.js.es6 @@ -1,12 +1,26 @@ /* global Vue */ - require('./tabs'); +const modalFilters = require('./filters'); (() => { const ModalStore = gl.issueBoards.ModalStore; gl.issueBoards.ModalHeader = Vue.extend({ mixins: [gl.issueBoards.ModalMixins], + props: { + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, + }, data() { return ModalStore.store; }, @@ -31,6 +45,7 @@ require('./tabs'); }, components: { 'modal-tabs': gl.issueBoards.ModalTabs, + modalFilters, }, template: ` <div> @@ -51,6 +66,11 @@ require('./tabs'); <div class="add-issues-search append-bottom-10" v-if="showSearch"> + <modal-filters + :project-id="projectId" + :milestone-path="milestonePath" + :label-path="labelPath"> + </modal-filters> <input placeholder="Search issues..." class="form-control" diff --git a/app/assets/javascripts/boards/components/modal/index.js.es6 b/app/assets/javascripts/boards/components/modal/index.js.es6 index d367b7e4246..f290cd13763 100644 --- a/app/assets/javascripts/boards/components/modal/index.js.es6 +++ b/app/assets/javascripts/boards/components/modal/index.js.es6 @@ -27,6 +27,18 @@ require('./empty_state'); type: String, required: true, }, + projectId: { + type: Number, + required: true, + }, + milestonePath: { + type: String, + required: true, + }, + labelPath: { + type: String, + required: true, + }, }, data() { return ModalStore.store; @@ -52,17 +64,27 @@ require('./empty_state'); this.issuesCount = false; } }, + filter: { + handler() { + this.loadIssues(true); + }, + deep: true, + }, }, methods: { searchOperation: _.debounce(function searchOperationDebounce() { this.loadIssues(true); }, 500), loadIssues(clearIssues = false) { - return gl.boardService.getBacklog({ + if (!this.showAddIssuesModal) return false; + + const queryData = Object.assign({}, this.filter, { search: this.searchTerm, page: this.page, per: this.perPage, - }).then((res) => { + }); + + return gl.boardService.getBacklog(queryData).then((res) => { const data = res.json(); if (clearIssues) { @@ -112,8 +134,13 @@ require('./empty_state'); class="add-issues-modal" v-if="showAddIssuesModal"> <div class="add-issues-container"> - <modal-header></modal-header> + <modal-header + :project-id="projectId" + :milestone-path="milestonePath" + :label-path="labelPath"> + </modal-header> <modal-list + :image="blankStateImage" :issue-link-base="issueLinkBase" :root-path="rootPath" v-if="!loading && showList"></modal-list> diff --git a/app/assets/javascripts/boards/components/modal/list.js.es6 b/app/assets/javascripts/boards/components/modal/list.js.es6 index d0901219216..3730c1ecaeb 100644 --- a/app/assets/javascripts/boards/components/modal/list.js.es6 +++ b/app/assets/javascripts/boards/components/modal/list.js.es6 @@ -14,6 +14,10 @@ type: String, required: true, }, + image: { + type: String, + required: true, + }, }, data() { return ModalStore.store; @@ -111,6 +115,19 @@ class="add-issues-list add-issues-list-columns" ref="list"> <div + class="empty-state add-issues-empty-state-filter text-center" + v-if="issuesCount > 0 && issues.length === 0"> + <div + class="svg-content" + v-html="image"> + </div> + <div class="text-content"> + <h4> + There are no issues to show. + </h4> + </div> + </div> + <div v-for="group in groupedIssues" class="add-issues-list-column"> <div diff --git a/app/assets/javascripts/boards/stores/modal_store.js.es6 b/app/assets/javascripts/boards/stores/modal_store.js.es6 index 73518b42b84..15fc6c79e8d 100644 --- a/app/assets/javascripts/boards/stores/modal_store.js.es6 +++ b/app/assets/javascripts/boards/stores/modal_store.js.es6 @@ -18,6 +18,17 @@ page: 1, perPage: 50, }; + + this.setDefaultFilter(); + } + + setDefaultFilter() { + this.store.filter = { + author_id: '', + assignee_id: '', + milestone_title: '', + label_name: [], + }; } selectedCount() { diff --git a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 b/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 deleted file mode 100644 index 54c2b4ad369..00000000000 --- a/app/assets/javascripts/boards/vue_resource_interceptor.js.es6 +++ /dev/null @@ -1,10 +0,0 @@ -/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars */ -/* global Vue */ - -Vue.http.interceptors.push((request, next) => { - Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; - - next(function (response) { - Vue.activeResources -= 1; - }); -}); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 new file mode 100644 index 00000000000..fbfec7743c7 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_bundle.js.es6 @@ -0,0 +1,26 @@ +/* eslint-disable no-new, no-param-reassign */ +/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ + +window.Vue = require('vue'); +require('./pipelines_table'); +/** + * Commits View > Pipelines Tab > Pipelines Table. + * Merge Request View > Pipelines Tab > Pipelines Table. + * + * Renders Pipelines table in pipelines tab in the commits show view. + * Renders Pipelines table in pipelines tab in the merge request show view. + */ + +$(() => { + window.gl = window.gl || {}; + gl.commits = gl.commits || {}; + gl.commits.pipelines = gl.commits.pipelines || {}; + + if (gl.commits.PipelinesTableBundle) { + gl.commits.PipelinesTableBundle.$destroy(true); + } + + gl.commits.pipelines.PipelinesTableBundle = new gl.commits.pipelines.PipelinesTableView({ + el: document.querySelector('#commit-pipeline-table-view'), + }); +}); diff --git a/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 new file mode 100644 index 00000000000..483b414126a --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_service.js.es6 @@ -0,0 +1,29 @@ +/* globals Vue */ +/* eslint-disable no-unused-vars, no-param-reassign */ + +/** + * Pipelines service. + * + * Used to fetch the data used to render the pipelines table. + * Uses Vue.Resource + */ +class PipelinesService { + constructor(endpoint) { + this.pipelines = Vue.resource(endpoint); + } + + /** + * Given the root param provided when the class is initialized, will + * make a GET request. + * + * @return {Promise} + */ + all() { + return this.pipelines.get(); + } +} + +window.gl = window.gl || {}; +gl.commits = gl.commits || {}; +gl.commits.pipelines = gl.commits.pipelines || {}; +gl.commits.pipelines.PipelinesService = PipelinesService; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 new file mode 100644 index 00000000000..f1b41911b73 --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_store.js.es6 @@ -0,0 +1,50 @@ +/* eslint-disable no-underscore-dangle*/ +/** + * Pipelines' Store for commits view. + * + * Used to store the Pipelines rendered in the commit view in the pipelines table. + */ + +class PipelinesStore { + constructor() { + this.state = {}; + this.state.pipelines = []; + } + + storePipelines(pipelines = []) { + this.state.pipelines = pipelines; + + return pipelines; + } + + /** + * Once the data is received we will start the time ago loops. + * + * Everytime a request is made like retry or cancel a pipeline, every 10 seconds we + * update the time to show how long as passed. + * + */ + startTimeAgoLoops() { + const startTimeLoops = () => { + this.timeLoopInterval = setInterval(() => { + this.$children[0].$children.reduce((acc, component) => { + const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; + acc.push(timeAgoComponent); + return acc; + }, []).forEach(e => e.changeTime()); + }, 10000); + }; + + startTimeLoops(); + + const removeIntervals = () => clearInterval(this.timeLoopInterval); + const startIntervals = () => startTimeLoops(); + + gl.VueRealtimeListener(removeIntervals, startIntervals); + } +} + +window.gl = window.gl || {}; +gl.commits = gl.commits || {}; +gl.commits.pipelines = gl.commits.pipelines || {}; +gl.commits.pipelines.PipelinesStore = PipelinesStore; diff --git a/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 new file mode 100644 index 00000000000..ce0dbd4d56b --- /dev/null +++ b/app/assets/javascripts/commit/pipelines/pipelines_table.js.es6 @@ -0,0 +1,107 @@ +/* eslint-disable no-new, no-param-reassign */ +/* global Vue, CommitsPipelineStore, PipelinesService, Flash */ + +window.Vue = require('vue'); +window.Vue.use(require('vue-resource')); +require('../../lib/utils/common_utils'); +require('../../vue_shared/vue_resource_interceptor'); +require('../../vue_shared/components/pipelines_table'); +require('../../vue_realtime_listener/index'); +require('./pipelines_service'); +require('./pipelines_store'); + +/** + * + * Uses `pipelines-table-component` to render Pipelines table with an API call. + * Endpoint is provided in HTML and passed as `endpoint`. + * We need a store to store the received environemnts. + * We need a service to communicate with the server. + * + * Necessary SVG in the table are provided as props. This should be refactored + * as soon as we have Webpack and can load them directly into JS files. + */ + +(() => { + window.gl = window.gl || {}; + gl.commits = gl.commits || {}; + gl.commits.pipelines = gl.commits.pipelines || {}; + + gl.commits.pipelines.PipelinesTableView = Vue.component('pipelines-table', { + + components: { + 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, + }, + + /** + * Accesses the DOM to provide the needed data. + * Returns the necessary props to render `pipelines-table-component` component. + * + * @return {Object} + */ + data() { + const pipelinesTableData = document.querySelector('#commit-pipeline-table-view').dataset; + const svgsData = document.querySelector('.pipeline-svgs').dataset; + const store = new gl.commits.pipelines.PipelinesStore(); + + // Transform svgs DOMStringMap to a plain Object. + const svgsObject = gl.utils.DOMStringMapToObject(svgsData); + + return { + endpoint: pipelinesTableData.endpoint, + svgs: svgsObject, + store, + state: store.state, + isLoading: false, + }; + }, + + /** + * When the component is created the service to fetch the data will be + * initialized with the correct endpoint. + * + * A request to fetch the pipelines will be made. + * In case of a successfull response we will store the data in the provided + * store, in case of a failed response we need to warn the user. + * + */ + created() { + const pipelinesService = new gl.commits.pipelines.PipelinesService(this.endpoint); + + this.isLoading = true; + return pipelinesService.all() + .then(response => response.json()) + .then((json) => { + this.store.storePipelines(json); + this.store.startTimeAgoLoops.call(this, Vue); + this.isLoading = false; + }) + .catch(() => { + this.isLoading = false; + new Flash('An error occurred while fetching the pipelines, please reload the page again.', 'alert'); + }); + }, + + template: ` + <div> + <div class="pipelines realtime-loading" v-if="isLoading"> + <i class="fa fa-spinner fa-spin"></i> + </div> + + <div class="blank-state blank-state-no-icon" + v-if="!isLoading && state.pipelines.length === 0"> + <h2 class="blank-state-title js-blank-state-title"> + No pipelines to show + </h2> + </div> + + <div class="table-holder pipelines" + v-if="!isLoading && state.pipelines.length > 0"> + <pipelines-table-component + :pipelines="state.pipelines" + :svgs="svgs"> + </pipelines-table-component> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/dispatcher.js.es6 b/app/assets/javascripts/dispatcher.js.es6 index edec21e3b63..70f467d608f 100644 --- a/app/assets/javascripts/dispatcher.js.es6 +++ b/app/assets/javascripts/dispatcher.js.es6 @@ -159,11 +159,6 @@ new ZenMode(); shortcut_handler = new ShortcutsNavigation(); break; - case 'projects:commit:pipelines': - new gl.MiniPipelineGraph({ - container: '.js-pipeline-table', - }); - break; case 'projects:commits:show': case 'projects:activity': shortcut_handler = new ShortcutsNavigation(); diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 index 521873b14b4..6a3d996f69c 100644 --- a/app/assets/javascripts/environments/components/environment_item.js.es6 +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -4,7 +4,7 @@ window.Vue = require('vue'); window.timeago = require('vendor/timeago'); require('../../lib/utils/text_utility'); -require('../../vue_common_component/commit'); +require('../../vue_shared/components/commit'); require('./environment_actions'); require('./environment_external_url'); require('./environment_stop'); diff --git a/app/assets/javascripts/environments/environments_bundle.js.es6 b/app/assets/javascripts/environments/environments_bundle.js.es6 index 58f4c6eadb2..05c59d92fd4 100644 --- a/app/assets/javascripts/environments/environments_bundle.js.es6 +++ b/app/assets/javascripts/environments/environments_bundle.js.es6 @@ -1,8 +1,7 @@ window.Vue = require('vue'); - require('./stores/environments_store'); require('./components/environment'); -require('./vue_resource_interceptor'); +require('../vue_shared/vue_resource_interceptor'); $(() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 b/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 deleted file mode 100644 index 406bdbc1c7d..00000000000 --- a/app/assets/javascripts/environments/vue_resource_interceptor.js.es6 +++ /dev/null @@ -1,12 +0,0 @@ -/* global Vue */ -Vue.http.interceptors.push((request, next) => { - Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; - - next((response) => { - if (typeof response.data === 'string') { - response.data = JSON.parse(response.data); // eslint-disable-line - } - - Vue.activeResources--; // eslint-disable-line - }); -}); diff --git a/app/assets/javascripts/labels_select.js b/app/assets/javascripts/labels_select.js index 70dc0d06b7b..e4cf9057e6d 100644 --- a/app/assets/javascripts/labels_select.js +++ b/app/assets/javascripts/labels_select.js @@ -4,10 +4,17 @@ (function() { this.LabelsSelect = (function() { - function LabelsSelect() { - var _this; + function LabelsSelect(els) { + var _this, $els; _this = this; - $('.js-label-select').each(function(i, dropdown) { + + $els = $(els); + + if (!els) { + $els = $('.js-label-select'); + } + + $els.each(function(i, dropdown) { var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer; $dropdown = $(dropdown); $dropdownContainer = $dropdown.closest('.labels-filter'); @@ -324,7 +331,7 @@ multiSelect: $dropdown.hasClass('js-multiselect'), vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(label, $el, e, isMarking) { - var isIssueIndex, isMRIndex, page; + var isIssueIndex, isMRIndex, page, boardsModel; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; @@ -346,22 +353,31 @@ return; } - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { + if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && + !$dropdown.closest('.add-issues-modal').length) { + boardsModel = gl.issueBoards.BoardsStore.state.filters; + } else if ($dropdown.closest('.add-issues-modal').length) { + boardsModel = gl.issueBoards.ModalStore.store.filter; + } + + if (boardsModel) { if (label.isAny) { - gl.issueBoards.BoardsStore.state.filters['label_name'] = []; + boardsModel['label_name'] = []; } else if ($el.hasClass('is-active')) { - gl.issueBoards.BoardsStore.state.filters['label_name'].push(label.title); + boardsModel['label_name'].push(label.title); } else { - var filters = gl.issueBoards.BoardsStore.state.filters['label_name']; + var filters = boardsModel['label_name']; filters = filters.filter(function (filteredLabel) { return filteredLabel !== label.title; }); - gl.issueBoards.BoardsStore.state.filters['label_name'] = filters; + boardsModel['label_name'] = filters; } - gl.issueBoards.BoardsStore.updateFiltersUrl(); + if (!$dropdown.closest('.add-issues-modal').length) { + gl.issueBoards.BoardsStore.updateFiltersUrl(); + } e.preventDefault(); return; } diff --git a/app/assets/javascripts/lib/utils/common_utils.js.es6 b/app/assets/javascripts/lib/utils/common_utils.js.es6 index e3bff2559fd..0ee29a75c62 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js.es6 +++ b/app/assets/javascripts/lib/utils/common_utils.js.es6 @@ -230,5 +230,16 @@ return upperCaseHeaders; }; + + /** + * Transforms a DOMStringMap into a plain object. + * + * @param {DOMStringMap} DOMStringMapObject + * @returns {Object} + */ + w.gl.utils.DOMStringMapToObject = DOMStringMapObject => Object.keys(DOMStringMapObject).reduce((acc, element) => { + acc[element] = DOMStringMapObject[element]; + return acc; + }, {}); })(window); }).call(this); diff --git a/app/assets/javascripts/merge_request_tabs.js.es6 b/app/assets/javascripts/merge_request_tabs.js.es6 index 7e74bebb81e..107e85f1225 100644 --- a/app/assets/javascripts/merge_request_tabs.js.es6 +++ b/app/assets/javascripts/merge_request_tabs.js.es6 @@ -61,7 +61,6 @@ require('./flash'); constructor({ action, setUrl, stubLocation } = {}) { this.diffsLoaded = false; - this.pipelinesLoaded = false; this.commitsLoaded = false; this.fixedLayoutPref = null; @@ -116,10 +115,6 @@ require('./flash'); $.scrollTo('.merge-request-details .merge-request-tabs', { offset: -navBarHeight, }); - } else if (action === 'pipelines') { - this.loadPipelines($target.attr('href')); - this.expandView(); - this.resetViewContainer(); } else { this.expandView(); this.resetViewContainer(); @@ -244,25 +239,6 @@ require('./flash'); }); } - loadPipelines(source) { - if (this.pipelinesLoaded) { - return; - } - this.ajaxGet({ - url: `${source}.json`, - success: (data) => { - $('#pipelines').html(data.html); - gl.utils.localTimeAgo($('.js-timeago', '#pipelines')); - this.pipelinesLoaded = true; - this.scrollToElement('#pipelines'); - - new gl.MiniPipelineGraph({ - container: '.js-pipeline-table', - }); - }, - }); - } - // Show or hide the loading spinner // // status - Boolean, true to show, false to hide diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 7ab39ffbd05..2f08aa7fe8b 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -5,13 +5,20 @@ (function() { this.MilestoneSelect = (function() { - function MilestoneSelect(currentProject) { - var _this; + function MilestoneSelect(currentProject, els) { + var _this, $els; if (currentProject != null) { _this = this; this.currentProject = JSON.parse(currentProject); } - $('.js-milestone-select').each(function(i, dropdown) { + + $els = $(els); + + if (!els) { + $els = $('.js-milestone-select'); + } + + $els.each(function(i, dropdown) { var $block, $dropdown, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, collapsedSidebarLabelTemplate, defaultLabel, issuableId, issueUpdateURL, milestoneLinkNoneTemplate, milestoneLinkTemplate, milestonesUrl, projectId, selectedMilestone, showAny, showNo, showUpcoming, useId, showMenuAbove; $dropdown = $(dropdown); projectId = $dropdown.data('project-id'); @@ -108,7 +115,7 @@ }, vue: $dropdown.hasClass('js-issue-board-sidebar'), clicked: function(selected, $el, e) { - var data, isIssueIndex, isMRIndex, page; + var data, isIssueIndex, isMRIndex, page, boardsStore; page = $('body').data('page'); isIssueIndex = page === 'projects:issues:index'; isMRIndex = (page === page && page === 'projects:merge_requests:index'); @@ -116,9 +123,19 @@ e.preventDefault(); return; } - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { - gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = selected.name; - gl.issueBoards.BoardsStore.updateFiltersUrl(); + + if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') && + !$dropdown.closest('.add-issues-modal').length) { + boardsStore = gl.issueBoards.BoardsStore.state.filters; + } else if ($dropdown.closest('.add-issues-modal').length) { + boardsStore = gl.issueBoards.ModalStore.store.filter; + } + + if (boardsStore) { + boardsStore[$dropdown.data('field-name')] = selected.name; + if (!$dropdown.closest('.add-issues-modal').length) { + gl.issueBoards.BoardsStore.updateFiltersUrl(); + } e.preventDefault(); } else if ($dropdown.hasClass('js-filter-submit') && (isIssueIndex || isMRIndex)) { if (selected.name != null) { diff --git a/app/assets/javascripts/users_select.js b/app/assets/javascripts/users_select.js index 77d2764cdf0..d4b24d13299 100644 --- a/app/assets/javascripts/users_select.js +++ b/app/assets/javascripts/users_select.js @@ -8,7 +8,8 @@ slice = [].slice; this.UsersSelect = (function() { - function UsersSelect(currentUser) { + function UsersSelect(currentUser, els) { + var $els; this.users = bind(this.users, this); this.user = bind(this.user, this); this.usersPath = "/autocomplete/users.json"; @@ -20,7 +21,14 @@ this.currentUser = JSON.parse(currentUser); } } - $('.js-user-search').each((function(_this) { + + $els = $(els); + + if (!els) { + $els = $('.js-user-search'); + } + + $els.each((function(_this) { return function(i, dropdown) { var options = {}; var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove; @@ -193,7 +201,9 @@ selectedId = user.id; return; } - if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { + if ($el.closest('.add-issues-modal').length) { + gl.issueBoards.ModalStore.store.filter[$dropdown.data('field-name')] = user.id; + } else if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { selectedId = user.id; gl.issueBoards.BoardsStore.state.filters[$dropdown.data('field-name')] = user.id; gl.issueBoards.BoardsStore.updateFiltersUrl(); diff --git a/app/assets/javascripts/vue_pipelines_index/index.js.es6 b/app/assets/javascripts/vue_pipelines_index/index.js.es6 index e1bebe0fe5b..e7432afb56e 100644 --- a/app/assets/javascripts/vue_pipelines_index/index.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/index.js.es6 @@ -1,41 +1,36 @@ +/* eslint-disable no-param-reassign */ /* global Vue, VueResource, gl */ window.Vue = require('vue'); window.Vue.use(require('vue-resource')); -require('../vue_common_component/commit'); -require('../vue_pagination/index'); -require('../boards/vue_resource_interceptor'); -require('./status'); -require('./store'); -require('./pipeline_url'); -require('./stage'); -require('./stages'); -require('./pipeline_actions'); -require('./time_ago'); +require('../lib/utils/common_utils'); +require('../vue_shared/vue_resource_interceptor'); require('./pipelines'); -(() => { - const project = document.querySelector('.pipelines'); - const entry = document.querySelector('.vue-pipelines-index'); - const svgs = document.querySelector('.pipeline-svgs'); +$(() => new Vue({ + el: document.querySelector('.vue-pipelines-index'), - if (!entry) return null; - return new Vue({ - el: entry, - data: { + data() { + const project = document.querySelector('.pipelines'); + const svgs = document.querySelector('.pipeline-svgs').dataset; + + // Transform svgs DOMStringMap to a plain Object. + const svgsObject = gl.utils.DOMStringMapToObject(svgs); + + return { scope: project.dataset.url, store: new gl.PipelineStore(), - svgs: svgs.dataset, - }, - components: { - 'vue-pipelines': gl.VuePipelines, - }, - template: ` - <vue-pipelines - :scope='scope' - :store='store' - :svgs='svgs' - > - </vue-pipelines> - `, - }); -})(); + svgs: svgsObject, + }; + }, + components: { + 'vue-pipelines': gl.VuePipelines, + }, + template: ` + <vue-pipelines + :scope='scope' + :store='store' + :svgs='svgs' + > + </vue-pipelines> + `, +})); diff --git a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 index 01f8b6519a4..8106934e864 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipeline_actions.js.es6 @@ -50,9 +50,9 @@ <button v-if='artifacts' class="dropdown-toggle btn btn-default build-artifacts has-tooltip js-pipeline-dropdown-download" - data-toggle="dropdown" title="Artifacts" data-placement="top" + data-toggle="dropdown" aria-label="Artifacts" > <i class="fa fa-download" aria-hidden="true"></i> @@ -81,8 +81,7 @@ data-placement="top" data-toggle="dropdown" :href='pipeline.retry_path' - aria-label="Retry" - > + aria-label="Retry"> <i class="fa fa-repeat" aria-hidden="true"></i> </a> <a @@ -94,8 +93,7 @@ data-placement="top" data-toggle="dropdown" :href='pipeline.cancel_path' - aria-label="Cancel" - > + aria-label="Cancel"> <i class="fa fa-remove" aria-hidden="true"></i> </a> </div> diff --git a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 index 194bbae07d9..e47dc6935d6 100644 --- a/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/pipelines.js.es6 @@ -1,19 +1,19 @@ /* global Vue, gl */ /* eslint-disable no-param-reassign */ +window.Vue = require('vue'); +require('../vue_shared/components/table_pagination'); +require('./store'); +require('../vue_shared/components/pipelines_table'); + ((gl) => { gl.VuePipelines = Vue.extend({ + components: { - runningPipeline: gl.VueRunningPipeline, - pipelineActions: gl.VuePipelineActions, - stages: gl.VueStages, - commit: gl.CommitComponent, - pipelineUrl: gl.VuePipelineUrl, - pipelineHead: gl.VuePipelineHead, - glPagination: gl.VueGlPagination, - statusScope: gl.VueStatusScope, - timeAgo: gl.VueTimeAgo, + 'gl-pagination': gl.VueGlPagination, + 'pipelines-table-component': gl.pipelines.PipelinesTableComponent, }, + data() { return { pipelines: [], @@ -38,87 +38,29 @@ change(pagenum, apiScope) { gl.utils.visitUrl(`?scope=${apiScope}&p=${pagenum}`); }, - author(pipeline) { - if (!pipeline.commit) return { avatar_url: '', web_url: '', username: '' }; - if (pipeline.commit.author) return pipeline.commit.author; - return { - avatar_url: pipeline.commit.author_gravatar_url, - web_url: `mailto:${pipeline.commit.author_email}`, - username: pipeline.commit.author_name, - }; - }, - ref(pipeline) { - const { ref } = pipeline; - return { name: ref.name, tag: ref.tag, ref_url: ref.path }; - }, - commitTitle(pipeline) { - return pipeline.commit ? pipeline.commit.title : ''; - }, - commitSha(pipeline) { - return pipeline.commit ? pipeline.commit.short_id : ''; - }, - commitUrl(pipeline) { - return pipeline.commit ? pipeline.commit.commit_path : ''; - }, - match(string) { - return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase()); - }, }, template: ` <div> - <div class="pipelines realtime-loading" v-if='pipelines.length < 1'> + <div class="pipelines realtime-loading" v-if='pageRequest'> <i class="fa fa-spinner fa-spin"></i> </div> - <div class="table-holder" v-if='pipelines.length'> - <table class="table ci-table"> - <thead> - <tr> - <th class="pipeline-status">Status</th> - <th class="pipeline-info">Pipeline</th> - <th class="pipeline-commit">Commit</th> - <th class="pipeline-stages">Stages</th> - <th class="pipeline-date"></th> - <th class="pipeline-actions hidden-xs"></th> - </tr> - </thead> - <tbody> - <tr class="commit" v-for='pipeline in pipelines'> - <status-scope - :pipeline='pipeline' - :match='match' - :svgs='svgs' - > - </status-scope> - <pipeline-url :pipeline='pipeline'></pipeline-url> - <td> - <commit - :commit-icon-svg='svgs.commitIconSvg' - :author='author(pipeline)' - :tag="pipeline.ref.tag" - :title='commitTitle(pipeline)' - :commit-ref='ref(pipeline)' - :short-sha='commitSha(pipeline)' - :commit-url='commitUrl(pipeline)' - > - </commit> - </td> - <stages - :pipeline='pipeline' - :svgs='svgs' - :match='match' - > - </stages> - <time-ago :pipeline='pipeline' :svgs='svgs'></time-ago> - <pipeline-actions :pipeline='pipeline' :svgs='svgs'></pipeline-actions> - </tr> - </tbody> - </table> + + <div class="blank-state blank-state-no-icon" + v-if="!pageRequest && pipelines.length === 0"> + <h2 class="blank-state-title js-blank-state-title"> + No pipelines to show + </h2> </div> - <div class="pipelines realtime-loading" v-if='pageRequest'> - <i class="fa fa-spinner fa-spin"></i> + + <div class="table-holder" v-if='!pageRequest && pipelines.length'> + <pipelines-table-component + :pipelines='pipelines' + :svgs='svgs'> + </pipelines-table-component> </div> + <gl-pagination - v-if='pageInfo.total > pageInfo.perPage' + v-if='!pageRequest && pipelines.length && pageInfo.total > pageInfo.perPage' :pagenum='pagenum' :change='change' :count='count.all' diff --git a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 index 496df9aaced..8cc417a9966 100644 --- a/app/assets/javascripts/vue_pipelines_index/stage.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/stage.js.es6 @@ -15,7 +15,7 @@ required: true, }, svgs: { - type: DOMStringMap, + type: Object, required: true, }, match: { diff --git a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 b/app/assets/javascripts/vue_pipelines_index/stages.js.es6 deleted file mode 100644 index cb176b3f0c6..00000000000 --- a/app/assets/javascripts/vue_pipelines_index/stages.js.es6 +++ /dev/null @@ -1,21 +0,0 @@ -/* global Vue, gl */ -/* eslint-disable no-param-reassign */ - -((gl) => { - gl.VueStages = Vue.extend({ - components: { - 'vue-stage': gl.VueStage, - }, - props: ['pipeline', 'svgs', 'match'], - template: ` - <td class="stage-cell"> - <div - class="stage-container dropdown js-mini-pipeline-graph" - v-for='stage in pipeline.details.stages' - > - <vue-stage :stage='stage' :svgs='svgs' :match='match'></vue-stage> - </div> - </td> - `, - }); -})(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/vue_pipelines_index/store.js.es6 b/app/assets/javascripts/vue_pipelines_index/store.js.es6 index 0f5ce2a9274..0ee21f00fdc 100644 --- a/app/assets/javascripts/vue_pipelines_index/store.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/store.js.es6 @@ -20,6 +20,7 @@ require('../vue_realtime_listener'); gl.PipelineStore = class { fetchDataLoop(Vue, pageNum, url, apiScope) { + this.pageRequest = true; const updatePipelineNums = (count) => { const { all } = count; const running = count.running_or_pending; @@ -41,16 +42,18 @@ require('../vue_realtime_listener'); this.pageRequest = false; }, () => { this.pageRequest = false; - return new Flash('Something went wrong on our end.'); + return new Flash('An error occurred while fetching the pipelines, please reload the page again.'); }); goFetch(); const startTimeLoops = () => { this.timeLoopInterval = setInterval(() => { - this.$children - .filter(e => e.$options._componentTag === 'time-ago') - .forEach(e => e.changeTime()); + this.$children[0].$children.reduce((acc, component) => { + const timeAgoComponent = component.$children.filter(el => el.$options._componentTag === 'time-ago')[0]; + acc.push(timeAgoComponent); + return acc; + }, []).forEach(e => e.changeTime()); }, 10000); }; diff --git a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 index 655110feba1..3598da11573 100644 --- a/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 +++ b/app/assets/javascripts/vue_pipelines_index/time_ago.js.es6 @@ -1,6 +1,9 @@ /* global Vue, gl */ /* eslint-disable no-param-reassign */ +window.Vue = require('vue'); +require('../lib/utils/datetime_utility'); + ((gl) => { gl.VueTimeAgo = Vue.extend({ data() { diff --git a/app/assets/javascripts/vue_common_component/commit.js.es6 b/app/assets/javascripts/vue_shared/components/commit.js.es6 index 4adad7bea31..7f7c18ddeb1 100644 --- a/app/assets/javascripts/vue_common_component/commit.js.es6 +++ b/app/assets/javascripts/vue_shared/components/commit.js.es6 @@ -1,7 +1,5 @@ /* global Vue */ -window.Vue = require('vue'); - (() => { window.gl = window.gl || {}; diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 new file mode 100644 index 00000000000..4bdaef31ee9 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipelines_table.js.es6 @@ -0,0 +1,61 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +require('./pipelines_table_row'); +/** + * Pipelines Table Component. + * + * Given an array of objects, renders a table. + */ + +(() => { + window.gl = window.gl || {}; + gl.pipelines = gl.pipelines || {}; + + gl.pipelines.PipelinesTableComponent = Vue.component('pipelines-table-component', { + + props: { + pipelines: { + type: Array, + required: true, + default: () => ([]), + }, + + /** + * TODO: Remove this when we have webpack. + */ + svgs: { + type: Object, + required: true, + default: () => ({}), + }, + }, + + components: { + 'pipelines-table-row-component': gl.pipelines.PipelinesTableRowComponent, + }, + + template: ` + <table class="table ci-table"> + <thead> + <tr> + <th class="js-pipeline-status pipeline-status">Status</th> + <th class="js-pipeline-info pipeline-info">Pipeline</th> + <th class="js-pipeline-commit pipeline-commit">Commit</th> + <th class="js-pipeline-stages pipeline-stages">Stages</th> + <th class="js-pipeline-date pipeline-date"></th> + <th class="js-pipeline-actions pipeline-actions hidden-xs"></th> + </tr> + </thead> + <tbody> + <template v-for="model in pipelines" + v-bind:model="model"> + <tr is="pipelines-table-row-component" + :pipeline="model" + :svgs="svgs"></tr> + </template> + </tbody> + </table> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 new file mode 100644 index 00000000000..c819f0dd7cd --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/pipelines_table_row.js.es6 @@ -0,0 +1,234 @@ +/* eslint-disable no-param-reassign */ +/* global Vue */ + +require('../../vue_pipelines_index/status'); +require('../../vue_pipelines_index/pipeline_url'); +require('../../vue_pipelines_index/stage'); +require('../../vue_pipelines_index/pipeline_actions'); +require('../../vue_pipelines_index/time_ago'); +require('./commit'); +/** + * Pipeline table row. + * + * Given the received object renders a table row in the pipelines' table. + */ +(() => { + window.gl = window.gl || {}; + gl.pipelines = gl.pipelines || {}; + + gl.pipelines.PipelinesTableRowComponent = Vue.component('pipelines-table-row-component', { + + props: { + pipeline: { + type: Object, + required: true, + default: () => ({}), + }, + + /** + * TODO: Remove this when we have webpack; + */ + svgs: { + type: Object, + required: true, + default: () => ({}), + }, + }, + + components: { + 'commit-component': gl.CommitComponent, + 'pipeline-actions': gl.VuePipelineActions, + 'dropdown-stage': gl.VueStage, + 'pipeline-url': gl.VuePipelineUrl, + 'status-scope': gl.VueStatusScope, + 'time-ago': gl.VueTimeAgo, + }, + + computed: { + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * This field needs a lot of verification, because of different possible cases: + * + * 1. person who is an author of a commit might be a GitLab user + * 2. if person who is an author of a commit is a GitLab user he/she can have a GitLab avatar + * 3. If GitLab user does not have avatar he/she might have a Gravatar + * 4. If committer is not a GitLab User he/she can have a Gravatar + * 5. We do not have consistent API object in this case + * 6. We should improve API and the code + * + * @returns {Object|Undefined} + */ + commitAuthor() { + let commitAuthorInformation; + + // 1. person who is an author of a commit might be a GitLab user + if (this.pipeline && + this.pipeline.commit && + this.pipeline.commit.author) { + // 2. if person who is an author of a commit is a GitLab user + // he/she can have a GitLab avatar + if (this.pipeline.commit.author.avatar_url) { + commitAuthorInformation = this.pipeline.commit.author; + + // 3. If GitLab user does not have avatar he/she might have a Gravatar + } else if (this.pipeline.commit.author_gravatar_url) { + commitAuthorInformation = Object.assign({}, this.pipeline.commit.author, { + avatar_url: this.pipeline.commit.author_gravatar_url, + }); + } + } + + // 4. If committer is not a GitLab User he/she can have a Gravatar + if (this.pipeline && + this.pipeline.commit) { + commitAuthorInformation = { + avatar_url: this.pipeline.commit.author_gravatar_url, + web_url: `mailto:${this.pipeline.commit.author_email}`, + username: this.pipeline.commit.author_name, + }; + } + + return commitAuthorInformation; + }, + + /** + * If provided, returns the commit tag. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTag() { + if (this.pipeline.ref && + this.pipeline.ref.tag) { + return this.pipeline.ref.tag; + } + return undefined; + }, + + /** + * If provided, returns the commit ref. + * Needed to render the commit component column. + * + * Matched `url` prop sent in the API to `path` prop needed + * in the commit component. + * + * @returns {Object|Undefined} + */ + commitRef() { + if (this.pipeline.ref) { + return Object.keys(this.pipeline.ref).reduce((accumulator, prop) => { + if (prop === 'url') { + accumulator.path = this.pipeline.ref[prop]; + } else { + accumulator[prop] = this.pipeline.ref[prop]; + } + return accumulator; + }, {}); + } + + return undefined; + }, + + /** + * If provided, returns the commit url. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitUrl() { + if (this.pipeline.commit && + this.pipeline.commit.commit_path) { + return this.pipeline.commit.commit_path; + } + return undefined; + }, + + /** + * If provided, returns the commit short sha. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitShortSha() { + if (this.pipeline.commit && + this.pipeline.commit.short_id) { + return this.pipeline.commit.short_id; + } + return undefined; + }, + + /** + * If provided, returns the commit title. + * Needed to render the commit component column. + * + * @returns {String|Undefined} + */ + commitTitle() { + if (this.pipeline.commit && + this.pipeline.commit.title) { + return this.pipeline.commit.title; + } + return undefined; + }, + }, + + methods: { + /** + * FIXME: This should not be in this component but in the components that + * need this function. + * + * Used to render SVGs in the following components: + * - status-scope + * - dropdown-stage + * + * @param {String} string + * @return {String} + */ + match(string) { + return string.replace(/_([a-z])/g, (m, w) => w.toUpperCase()); + }, + }, + + template: ` + <tr class="commit"> + <status-scope + :pipeline="pipeline" + :svgs="svgs" + :match="match"> + </status-scope> + + <pipeline-url :pipeline="pipeline"></pipeline-url> + + <td> + <commit-component + :tag="commitTag" + :commit-ref="commitRef" + :commit-url="commitUrl" + :short-sha="commitShortSha" + :title="commitTitle" + :author="commitAuthor" + :commit-icon-svg="svgs.commitIconSvg"> + </commit-component> + </td> + + <td class="stage-cell"> + <div class="stage-container dropdown js-mini-pipeline-graph" + v-if="pipeline.details.stages.length > 0" + v-for="stage in pipeline.details.stages"> + <dropdown-stage + :stage="stage" + :svgs="svgs" + :match="match"> + </dropdown-stage> + </div> + </td> + + <time-ago :pipeline="pipeline" :svgs="svgs"></time-ago> + + <pipeline-actions :pipeline="pipeline" :svgs="svgs"></pipeline-actions> + </tr> + `, + }); +})(); diff --git a/app/assets/javascripts/vue_pagination/index.js.es6 b/app/assets/javascripts/vue_shared/components/table_pagination.js.es6 index 67c6cb73761..67c6cb73761 100644 --- a/app/assets/javascripts/vue_pagination/index.js.es6 +++ b/app/assets/javascripts/vue_shared/components/table_pagination.js.es6 diff --git a/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 new file mode 100644 index 00000000000..d3229f9f730 --- /dev/null +++ b/app/assets/javascripts/vue_shared/vue_resource_interceptor.js.es6 @@ -0,0 +1,23 @@ +/* eslint-disable func-names, prefer-arrow-callback, no-unused-vars, +no-param-reassign, no-plusplus */ +/* global Vue */ + +Vue.http.interceptors.push((request, next) => { + Vue.activeResources = Vue.activeResources ? Vue.activeResources + 1 : 1; + + next((response) => { + if (typeof response.data === 'string') { + response.data = JSON.parse(response.data); + } + + Vue.activeResources--; + }); +}); + +Vue.http.interceptors.push((request, next) => { + // needed in order to not break the tests. + if ($.rails) { + request.headers['X-CSRF-Token'] = $.rails.csrfToken(); + } + next(); +}); diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 0ce94a26a7f..a4b38723bbd 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -253,6 +253,8 @@ li.note { .progress { margin-bottom: 0; margin-top: 4px; + box-shadow: none; + background-color: $border-gray-light; } } diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 9b413f3e61c..b362cc758cc 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -389,6 +389,13 @@ flex: 1; margin-top: 0; + &.add-issues-empty-state-filter { + -webkit-flex-direction: column; + flex-direction: column; + -webkit-justify-content: center; + justify-content: center; + } + > .row { width: 100%; margin: auto 0; @@ -416,6 +423,14 @@ .add-issues-search { display: -webkit-flex; display: flex; + + .form-control { + margin-left: auto; + + @media (min-width: $screen-sm-min) { + max-width: 200px; + } + } } .add-issues-list-column { @@ -486,3 +501,24 @@ line-height: 15px; border-radius: 50%; } + +.modal-filters { + display: flex; + + > .dropdown { + display: none; + margin-right: 10px; + + @media (min-width: $screen-sm-min) { + display: block; + } + } + + .dropdown-menu-toggle { + width: 100px; + + @media (min-width: $screen-md-min) { + width: 140px; + } + } +} diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 3f10ae81767..b0f5d4a9933 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -109,6 +109,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :plantuml_url, :max_artifacts_size, :max_attachment_size, + :max_pages_size, :metrics_enabled, :metrics_host, :metrics_method_call_threshold, diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index b5a7078a3a1..f880a9862c6 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -37,7 +37,6 @@ class Projects::CommitController < Projects::ApplicationController format.json do render json: PipelineSerializer .new(project: @project, user: @current_user) - .with_pagination(request, response) .represent(@pipelines) end end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6eb542e4bd8..38a1946a71e 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -216,19 +216,22 @@ class Projects::MergeRequestsController < Projects::ApplicationController end format.json do - render json: { - html: view_to_html_string('projects/merge_requests/show/_pipelines'), - pipelines: PipelineSerializer - .new(project: @project, user: @current_user) - .with_pagination(request, response) - .represent(@pipelines) - } + render json: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipelines) end end end def new - define_new_vars + respond_to do |format| + format.html { define_new_vars } + format.json do + render json: { pipelines: PipelineSerializer + .new(project: @project, user: @current_user) + .represent(@pipelines) } + end + end end def new_diffs diff --git a/app/controllers/projects/pages_controller.rb b/app/controllers/projects/pages_controller.rb new file mode 100644 index 00000000000..fbd18b68141 --- /dev/null +++ b/app/controllers/projects/pages_controller.rb @@ -0,0 +1,22 @@ +class Projects::PagesController < Projects::ApplicationController + layout 'project_settings' + + before_action :authorize_read_pages!, only: [:show] + before_action :authorize_update_pages!, except: [:show] + + def show + @domains = @project.pages_domains.order(:domain) + end + + def destroy + project.remove_pages + project.pages_domains.destroy_all + + respond_to do |format| + format.html do + redirect_to(namespace_project_pages_path(@project.namespace, @project), + notice: 'Pages were removed') + end + end + end +end diff --git a/app/controllers/projects/pages_domains_controller.rb b/app/controllers/projects/pages_domains_controller.rb new file mode 100644 index 00000000000..b8c253f6ae3 --- /dev/null +++ b/app/controllers/projects/pages_domains_controller.rb @@ -0,0 +1,49 @@ +class Projects::PagesDomainsController < Projects::ApplicationController + layout 'project_settings' + + before_action :authorize_update_pages!, except: [:show] + before_action :domain, only: [:show, :destroy] + + def show + end + + def new + @domain = @project.pages_domains.new + end + + def create + @domain = @project.pages_domains.create(pages_domain_params) + + if @domain.valid? + redirect_to namespace_project_pages_path(@project.namespace, @project) + else + render 'new' + end + end + + def destroy + @domain.destroy + + respond_to do |format| + format.html do + redirect_to(namespace_project_pages_path(@project.namespace, @project), + notice: 'Domain was removed') + end + format.js + end + end + + private + + def pages_domain_params + params.require(:pages_domain).permit( + :certificate, + :key, + :domain + ) + end + + def domain + @domain ||= @project.pages_domains.find_by(domain: params[:id].to_s) + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index b1f77bf242c..3c1a1ae5933 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -41,7 +41,7 @@ module Ci before_save :update_artifacts_size, if: :artifacts_file_changed? before_save :ensure_token - before_destroy { project } + before_destroy { unscoped_project } after_create :execute_hooks after_save :update_project_statistics, if: :artifacts_size_changed? @@ -256,7 +256,7 @@ module Ci end def project_id - pipeline.project_id + gl_project_id end def project_name @@ -416,16 +416,23 @@ module Ci # This method returns old path to artifacts only if it already exists. # def artifacts_path + # We need the project even if it's soft deleted, because whenever + # we're really deleting the project, we'll also delete the builds, + # and in order to delete the builds, we need to know where to find + # the artifacts, which is depending on the data of the project. + # We need to retain the project in this case. + the_project = project || unscoped_project + old = File.join(created_at.utc.strftime('%Y_%m'), - project.ci_id.to_s, + the_project.ci_id.to_s, id.to_s) old_store = File.join(ArtifactUploader.artifacts_path, old) - return old if project.ci_id && File.directory?(old_store) + return old if the_project.ci_id && File.directory?(old_store) File.join( created_at.utc.strftime('%Y_%m'), - project.id.to_s, + the_project.id.to_s, id.to_s ) end @@ -451,6 +458,7 @@ module Ci build_data = Gitlab::DataBuilder::Build.build(self) project.execute_hooks(build_data.dup, :build_hooks) project.execute_services(build_data.dup, :build_hooks) + PagesService.new(build_data).execute project.running_or_pending_build_count(force: true) end @@ -559,6 +567,10 @@ module Ci self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil) end + def unscoped_project + @unscoped_project ||= Project.unscoped.find_by(id: gl_project_id) + end + def predefined_variables variables = [ { key: 'CI', value: 'true', public: true }, @@ -597,6 +609,8 @@ module Ci end def update_project_statistics + return unless project + ProjectCacheWorker.perform_async(project_id, [], [:build_artifacts_size]) end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 67d8c1c2e4c..2fb2eb44aaa 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -130,6 +130,7 @@ class Namespace < ActiveRecord::Base end Gitlab::UploadsTransfer.new.rename_namespace(path_was, path) + Gitlab::PagesTransfer.new.rename_namespace(path_was, path) remove_exports! diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb new file mode 100644 index 00000000000..0b9ebf1ffe2 --- /dev/null +++ b/app/models/pages_domain.rb @@ -0,0 +1,119 @@ +class PagesDomain < ActiveRecord::Base + belongs_to :project + + validates :domain, hostname: true + validates_uniqueness_of :domain, case_sensitive: false + validates :certificate, certificate: true, allow_nil: true, allow_blank: true + validates :key, certificate_key: true, allow_nil: true, allow_blank: true + + validate :validate_pages_domain + validate :validate_matching_key, if: ->(domain) { domain.certificate.present? || domain.key.present? } + validate :validate_intermediates, if: ->(domain) { domain.certificate.present? } + + attr_encrypted :key, + mode: :per_attribute_iv_and_salt, + insecure_mode: true, + key: Gitlab::Application.secrets.db_key_base, + algorithm: 'aes-256-cbc' + + after_create :update + after_save :update + after_destroy :update + + def to_param + domain + end + + def url + return unless domain + + if certificate + "https://#{domain}" + else + "http://#{domain}" + end + end + + def has_matching_key? + return false unless x509 + return false unless pkey + + # We compare the public key stored in certificate with public key from certificate key + x509.check_private_key(pkey) + end + + def has_intermediates? + return false unless x509 + + # self-signed certificates doesn't have the certificate chain + return true if x509.verify(x509.public_key) + + store = OpenSSL::X509::Store.new + store.set_default_paths + + # This forces to load all intermediate certificates stored in `certificate` + Tempfile.open('certificate_chain') do |f| + f.write(certificate) + f.flush + store.add_file(f.path) + end + + store.verify(x509) + rescue OpenSSL::X509::StoreError + false + end + + def expired? + return false unless x509 + current = Time.new + current < x509.not_before || x509.not_after < current + end + + def subject + return unless x509 + x509.subject.to_s + end + + def certificate_text + @certificate_text ||= x509.try(:to_text) + end + + private + + def update + ::Projects::UpdatePagesConfigurationService.new(project).execute + end + + def validate_matching_key + unless has_matching_key? + self.errors.add(:key, "doesn't match the certificate") + end + end + + def validate_intermediates + unless has_intermediates? + self.errors.add(:certificate, 'misses intermediates') + end + end + + def validate_pages_domain + return unless domain + if domain.downcase.ends_with?(".#{Settings.pages.host}".downcase) + self.errors.add(:domain, "*.#{Settings.pages.host} is restricted") + end + end + + def x509 + return unless certificate + @x509 ||= OpenSSL::X509::Certificate.new(certificate) + rescue OpenSSL::X509::CertificateError + nil + end + + def pkey + return unless key + @pkey ||= OpenSSL::PKey::RSA.new(key) + rescue OpenSSL::PKey::PKeyError, OpenSSL::Cipher::CipherError + nil + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 0d286bfbaa8..7c5fdad5122 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -53,6 +53,8 @@ class Project < ActiveRecord::Base update_column(:last_activity_at, self.created_at) end + after_destroy :remove_pages + # update visibility_level of forks after_update :update_forks_visibility_level def update_forks_visibility_level @@ -148,6 +150,7 @@ class Project < ActiveRecord::Base has_many :lfs_objects, through: :lfs_objects_projects has_many :project_group_links, dependent: :destroy has_many :invited_groups, through: :project_group_links, source: :group + has_many :pages_domains, dependent: :destroy has_many :todos, dependent: :destroy has_many :notification_settings, dependent: :destroy, as: :source @@ -955,6 +958,7 @@ class Project < ActiveRecord::Base Gitlab::AppLogger.info "Project was renamed: #{old_path_with_namespace} -> #{new_path_with_namespace}" Gitlab::UploadsTransfer.new.rename_project(path_was, path, namespace.path) + Gitlab::PagesTransfer.new.rename_project(path_was, path, namespace.path) end # Expires various caches before a project is renamed. @@ -1156,6 +1160,45 @@ class Project < ActiveRecord::Base ensure_runners_token! end + def pages_deployed? + Dir.exist?(public_pages_path) + end + + def pages_url + # The hostname always needs to be in downcased + # All web servers convert hostname to lowercase + host = "#{namespace.path}.#{Settings.pages.host}".downcase + + # The host in URL always needs to be downcased + url = Gitlab.config.pages.url.sub(/^https?:\/\//) do |prefix| + "#{prefix}#{namespace.path}." + end.downcase + + # If the project path is the same as host, we serve it as group page + return url if host == path + + "#{url}/#{path}" + end + + def pages_path + File.join(Settings.pages.path, path_with_namespace) + end + + def public_pages_path + File.join(pages_path, 'public') + end + + def remove_pages + # 1. We rename pages to temporary directory + # 2. We wait 5 minutes, due to NFS caching + # 3. We asynchronously remove pages with force + temp_path = "#{path}.#{SecureRandom.hex}.deleted" + + if Gitlab::PagesTransfer.new.rename_project(path, temp_path, namespace.path) + PagesWorker.perform_in(5.minutes, :remove, namespace.path, temp_path) + end + end + def wiki @wiki ||= ProjectWiki.new(self, self.owner) end diff --git a/app/models/user.rb b/app/models/user.rb index 54f5388eb2c..6c98224de35 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -83,8 +83,6 @@ class User < ActiveRecord::Base has_many :events, dependent: :destroy, foreign_key: :author_id has_many :subscriptions, dependent: :destroy has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event" - has_many :assigned_issues, dependent: :destroy, foreign_key: :assignee_id, class_name: "Issue" - has_many :assigned_merge_requests, dependent: :destroy, foreign_key: :assignee_id, class_name: "MergeRequest" has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy has_one :abuse_report, dependent: :destroy has_many :spam_logs, dependent: :destroy @@ -94,6 +92,9 @@ class User < ActiveRecord::Base has_many :notification_settings, dependent: :destroy has_many :award_emoji, dependent: :destroy + has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue" + has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest" + # # Validations # diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 71ef8901932..f5fd50745aa 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -110,6 +110,9 @@ class ProjectPolicy < BasePolicy can! :admin_pipeline can! :admin_environment can! :admin_deployment + can! :admin_pages + can! :read_pages + can! :update_pages end def public_access! @@ -136,6 +139,7 @@ class ProjectPolicy < BasePolicy can! :remove_fork_project can! :destroy_merge_request can! :destroy_issue + can! :remove_pages end def team_member_owner_access! diff --git a/app/services/pages_service.rb b/app/services/pages_service.rb new file mode 100644 index 00000000000..446eeb34d3b --- /dev/null +++ b/app/services/pages_service.rb @@ -0,0 +1,15 @@ +class PagesService + attr_reader :data + + def initialize(data) + @data = data + end + + def execute + return unless Settings.pages.enabled + return unless data[:build_name] == 'pages' + return unless data[:build_status] == 'success' + + PagesWorker.perform_async(:deploy, data[:build_id]) + end +end diff --git a/app/services/projects/transfer_service.rb b/app/services/projects/transfer_service.rb index 34ec575e808..20b049b5973 100644 --- a/app/services/projects/transfer_service.rb +++ b/app/services/projects/transfer_service.rb @@ -64,6 +64,9 @@ module Projects # Move uploads Gitlab::UploadsTransfer.new.move_project(project.path, old_namespace.path, new_namespace.path) + # Move pages + Gitlab::PagesTransfer.new.move_project(project.path, old_namespace.path, new_namespace.path) + project.old_path_with_namespace = old_path SystemHooksService.new.execute_hooks_for(project, :transfer) diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb new file mode 100644 index 00000000000..eb4809afa85 --- /dev/null +++ b/app/services/projects/update_pages_configuration_service.rb @@ -0,0 +1,69 @@ +module Projects + class UpdatePagesConfigurationService < BaseService + attr_reader :project + + def initialize(project) + @project = project + end + + def execute + update_file(pages_config_file, pages_config.to_json) + reload_daemon + success + rescue => e + error(e.message) + end + + private + + def pages_config + { + domains: pages_domains_config + } + end + + def pages_domains_config + project.pages_domains.map do |domain| + { + domain: domain.domain, + certificate: domain.certificate, + key: domain.key, + } + end + end + + def reload_daemon + # GitLab Pages daemon constantly watches for modification time of `pages.path` + # It reloads configuration when `pages.path` is modified + update_file(pages_update_file, SecureRandom.hex(64)) + end + + def pages_path + @pages_path ||= project.pages_path + end + + def pages_config_file + File.join(pages_path, 'config.json') + end + + def pages_update_file + File.join(::Settings.pages.path, '.update') + end + + def update_file(file, data) + unless data + FileUtils.remove(file, force: true) + return + end + + temp_file = "#{file}.#{SecureRandom.hex(16)}" + File.open(temp_file, 'w') do |f| + f.write(data) + end + FileUtils.move(temp_file, file, force: true) + ensure + # In case if the updating fails + FileUtils.remove(temp_file, force: true) + end + end +end diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb new file mode 100644 index 00000000000..f5f9ee88912 --- /dev/null +++ b/app/services/projects/update_pages_service.rb @@ -0,0 +1,164 @@ +module Projects + class UpdatePagesService < BaseService + BLOCK_SIZE = 32.kilobytes + MAX_SIZE = 1.terabyte + SITE_PATH = 'public/' + + attr_reader :build + + def initialize(project, build) + @project, @build = project, build + end + + def execute + # Create status notifying the deployment of pages + @status = create_status + @status.enqueue! + @status.run! + + raise 'missing pages artifacts' unless build.artifacts_file? + raise 'pages are outdated' unless latest? + + # Create temporary directory in which we will extract the artifacts + FileUtils.mkdir_p(tmp_path) + Dir.mktmpdir(nil, tmp_path) do |archive_path| + extract_archive!(archive_path) + + # Check if we did extract public directory + archive_public_path = File.join(archive_path, 'public') + raise 'pages miss the public folder' unless Dir.exist?(archive_public_path) + raise 'pages are outdated' unless latest? + + deploy_page!(archive_public_path) + success + end + rescue => e + error(e.message) + end + + private + + def success + @status.success + super + end + + def error(message, http_status = nil) + @status.allow_failure = !latest? + @status.description = message + @status.drop + super + end + + def create_status + GenericCommitStatus.new( + project: project, + pipeline: build.pipeline, + user: build.user, + ref: build.ref, + stage: 'deploy', + name: 'pages:deploy' + ) + end + + def extract_archive!(temp_path) + if artifacts.ends_with?('.tar.gz') || artifacts.ends_with?('.tgz') + extract_tar_archive!(temp_path) + elsif artifacts.ends_with?('.zip') + extract_zip_archive!(temp_path) + else + raise 'unsupported artifacts format' + end + end + + def extract_tar_archive!(temp_path) + results = Open3.pipeline(%W(gunzip -c #{artifacts}), + %W(dd bs=#{BLOCK_SIZE} count=#{blocks}), + %W(tar -x -C #{temp_path} #{SITE_PATH}), + err: '/dev/null') + raise 'pages failed to extract' unless results.compact.all?(&:success?) + end + + def extract_zip_archive!(temp_path) + raise 'missing artifacts metadata' unless build.artifacts_metadata? + + # Calculate page size after extract + public_entry = build.artifacts_metadata_entry(SITE_PATH, recursive: true) + + if public_entry.total_size > max_size + raise "artifacts for pages are too large: #{public_entry.total_size}" + end + + # Requires UnZip at least 6.00 Info-ZIP. + # -n never overwrite existing files + # We add * to end of SITE_PATH, because we want to extract SITE_PATH and all subdirectories + site_path = File.join(SITE_PATH, '*') + unless system(*%W(unzip -n #{artifacts} #{site_path} -d #{temp_path})) + raise 'pages failed to extract' + end + end + + def deploy_page!(archive_public_path) + # Do atomic move of pages + # Move and removal may not be atomic, but they are significantly faster then extracting and removal + # 1. We move deployed public to previous public path (file removal is slow) + # 2. We move temporary public to be deployed public + # 3. We remove previous public path + FileUtils.mkdir_p(pages_path) + begin + FileUtils.move(public_path, previous_public_path) + rescue + end + FileUtils.move(archive_public_path, public_path) + ensure + FileUtils.rm_r(previous_public_path, force: true) + end + + def latest? + # check if sha for the ref is still the most recent one + # this helps in case when multiple deployments happens + sha == latest_sha + end + + def blocks + # Calculate dd parameters: we limit the size of pages + 1 + max_size / BLOCK_SIZE + end + + def max_size + current_application_settings.max_pages_size.megabytes || MAX_SIZE + end + + def tmp_path + @tmp_path ||= File.join(::Settings.pages.path, 'tmp') + end + + def pages_path + @pages_path ||= project.pages_path + end + + def public_path + @public_path ||= File.join(pages_path, 'public') + end + + def previous_public_path + @previous_public_path ||= File.join(pages_path, "public.#{SecureRandom.hex}") + end + + def ref + build.ref + end + + def artifacts + build.artifacts_file.path + end + + def latest_sha + project.commit(build.ref).try(:sha).to_s + end + + def sha + build.sha + end + end +end diff --git a/app/validators/certificate_key_validator.rb b/app/validators/certificate_key_validator.rb new file mode 100644 index 00000000000..098b16017d2 --- /dev/null +++ b/app/validators/certificate_key_validator.rb @@ -0,0 +1,25 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate_key: true +# end +# +class CertificateKeyValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless valid_private_key_pem?(value) + record.errors.add(attribute, "must be a valid PEM private key") + end + end + + private + + def valid_private_key_pem?(value) + return false unless value + pkey = OpenSSL::PKey::RSA.new(value) + pkey.private? + rescue OpenSSL::PKey::PKeyError + false + end +end diff --git a/app/validators/certificate_validator.rb b/app/validators/certificate_validator.rb new file mode 100644 index 00000000000..e3d18097f71 --- /dev/null +++ b/app/validators/certificate_validator.rb @@ -0,0 +1,24 @@ +# UrlValidator +# +# Custom validator for private keys. +# +# class Project < ActiveRecord::Base +# validates :certificate_key, certificate: true +# end +# +class CertificateValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless valid_certificate_pem?(value) + record.errors.add(attribute, "must be a valid PEM certificate") + end + end + + private + + def valid_certificate_pem?(value) + return false unless value + OpenSSL::X509::Certificate.new(value).present? + rescue OpenSSL::X509::CertificateError + false + end +end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 2c64de1d530..816035ec442 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -187,6 +187,14 @@ .help-block Markdown enabled %fieldset + %legend Pages + .form-group + = f.label :max_pages_size, 'Maximum size of pages (MB)', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :max_pages_size, class: 'form-control' + .help-block Zero for unlimited + + %fieldset %legend Continuous Integration .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index c6df66d2c3c..d6c158b6de3 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -34,3 +34,7 @@ = link_to namespace_project_pipelines_settings_path(@project.namespace, @project), title: 'CI/CD Pipelines' do %span CI/CD Pipelines + = nav_link(controller: :pages) do + = link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages', data: {placement: 'right'} do + %span + Pages diff --git a/app/views/projects/boards/_show.html.haml b/app/views/projects/boards/_show.html.haml index f0c76af29dc..05fe504d1c9 100644 --- a/app/views/projects/boards/_show.html.haml +++ b/app/views/projects/boards/_show.html.haml @@ -29,5 +29,8 @@ = render "projects/boards/components/sidebar" %board-add-issues-modal{ "blank-state-image" => render('shared/empty_states/icons/issues.svg'), "new-issue-path" => new_namespace_project_issue_path(@project.namespace, @project), + "milestone-path" => milestones_filter_dropdown_path, + "label-path" => labels_filter_path, ":issue-link-base" => "issueLinkBase", - ":root-path" => "rootPath" } + ":root-path" => "rootPath", + ":project-id" => @project.try(:id) } diff --git a/app/views/projects/commit/_pipelines_list.haml b/app/views/projects/commit/_pipelines_list.haml index 1164627fa11..aae2cb8a04b 100644 --- a/app/views/projects/commit/_pipelines_list.haml +++ b/app/views/projects/commit/_pipelines_list.haml @@ -1,15 +1,25 @@ -%div - - if pipelines.blank? - %div - .nothing-here-block No pipelines to show - - else - .table-holder.pipelines - %table.table.ci-table.js-pipeline-table - %thead - %th.pipeline-status Status - %th.pipeline-info Pipeline - %th.pipeline-commit Commit - %th.pipeline-stages Stages - %th.pipeline-date - %th.pipeline-actions - = render pipelines, commit_sha: true, stage: true, allow_retry: true, show_commit: false +#commit-pipeline-table-view{ data: { endpoint: endpoint } } +.pipeline-svgs{ data: { "commit_icon_svg" => custom_icon("icon_commit"), + "icon_status_canceled" => custom_icon("icon_status_canceled"), + "icon_status_running" => custom_icon("icon_status_running"), + "icon_status_skipped" => custom_icon("icon_status_skipped"), + "icon_status_created" => custom_icon("icon_status_created"), + "icon_status_pending" => custom_icon("icon_status_pending"), + "icon_status_success" => custom_icon("icon_status_success"), + "icon_status_failed" => custom_icon("icon_status_failed"), + "icon_status_warning" => custom_icon("icon_status_warning"), + "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), + "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), + "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), + "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), + "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), + "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), + "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), + "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), + "icon_play" => custom_icon("icon_play"), + "icon_timer" => custom_icon("icon_timer"), + "icon_status_manual" => custom_icon("icon_status_manual"), +} } + +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('commit_pipelines') diff --git a/app/views/projects/commit/pipelines.html.haml b/app/views/projects/commit/pipelines.html.haml index 89968cf4e0d..ac93eac41ac 100644 --- a/app/views/projects/commit/pipelines.html.haml +++ b/app/views/projects/commit/pipelines.html.haml @@ -2,4 +2,4 @@ = render 'commit_box' = render 'ci_menu' -= render 'pipelines_list', pipelines: @pipelines += render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_commit_path(@project.namespace, @project, @commit.id) diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 7a2dacdb1e7..9c5c1a6d707 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -133,6 +133,7 @@ %hr = link_to 'Remove avatar', namespace_project_avatar_path(@project.namespace, @project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" = f.submit 'Save changes', class: "btn btn-save" + .row.prepend-top-default %hr .row.prepend-top-default diff --git a/app/views/projects/merge_requests/_new_submit.html.haml b/app/views/projects/merge_requests/_new_submit.html.haml index d3c013b3f21..38259faf62f 100644 --- a/app/views/projects/merge_requests/_new_submit.html.haml +++ b/app/views/projects/merge_requests/_new_submit.html.haml @@ -46,7 +46,7 @@ -# This tab is always loaded via AJAX - if @pipelines.any? #pipelines.pipelines.tab-pane - = render "projects/merge_requests/show/pipelines" + = render "projects/merge_requests/show/pipelines", endpoint: link_to(url_for(params)) .mr-loading-status = spinner diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index b46c4a13cc4..83250443bea 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -94,7 +94,8 @@ #commits.commits.tab-pane -# This tab is always loaded via AJAX #pipelines.pipelines.tab-pane - -# This tab is always loaded via AJAX + - if @pipelines.any? + = render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) #diffs.diffs.tab-pane -# This tab is always loaded via AJAX diff --git a/app/views/projects/merge_requests/show/_pipelines.html.haml b/app/views/projects/merge_requests/show/_pipelines.html.haml index afe3f3430c6..cbe534abedb 100644 --- a/app/views/projects/merge_requests/show/_pipelines.html.haml +++ b/app/views/projects/merge_requests/show/_pipelines.html.haml @@ -1 +1 @@ -= render "projects/commit/pipelines_list", pipelines: @pipelines, link_to_commit: true += render 'projects/commit/pipelines_list', endpoint: pipelines_namespace_project_merge_request_path(@project.namespace, @project, @merge_request) diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml new file mode 100644 index 00000000000..82e20eeebb3 --- /dev/null +++ b/app/views/projects/pages/_access.html.haml @@ -0,0 +1,13 @@ +- if @project.pages_deployed? + .panel.panel-default + .panel-heading + Access pages + .panel-body + %p + %strong + Congratulations! Your pages are served under: + + %p= link_to @project.pages_url, @project.pages_url + + - @project.pages_domains.each do |domain| + %p= link_to domain.url, domain.url diff --git a/app/views/projects/pages/_destroy.haml b/app/views/projects/pages/_destroy.haml new file mode 100644 index 00000000000..42d9ef5ccba --- /dev/null +++ b/app/views/projects/pages/_destroy.haml @@ -0,0 +1,12 @@ +- if @project.pages_deployed? + - if can?(current_user, :remove_pages, @project) + .panel.panel-default.panel.panel-danger + .panel-heading Remove pages + .errors-holder + .panel-body + %p + Removing the pages will prevent from exposing them to outside world. + .form-actions + = link_to 'Remove pages', namespace_project_pages_path(@project.namespace, @project), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove" + - else + .nothing-here-block Only the project owner can remove pages diff --git a/app/views/projects/pages/_disabled.html.haml b/app/views/projects/pages/_disabled.html.haml new file mode 100644 index 00000000000..ad51fbc6cab --- /dev/null +++ b/app/views/projects/pages/_disabled.html.haml @@ -0,0 +1,4 @@ +.panel.panel-default + .nothing-here-block + GitLab Pages are disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/_list.html.haml b/app/views/projects/pages/_list.html.haml new file mode 100644 index 00000000000..4f2dd1a1398 --- /dev/null +++ b/app/views/projects/pages/_list.html.haml @@ -0,0 +1,17 @@ +- if can?(current_user, :update_pages, @project) && @domains.any? + .panel.panel-default + .panel-heading + Domains (#{@domains.count}) + %ul.well-list + - @domains.each do |domain| + %li + .pull-right + = link_to 'Details', namespace_project_pages_domain_path(@project.namespace, @project, domain), class: "btn btn-sm btn-grouped" + = link_to 'Remove', namespace_project_pages_domain_path(@project.namespace, @project, domain), data: { confirm: 'Are you sure?'}, method: :delete, class: "btn btn-remove btn-sm btn-grouped" + .clearfix + %span= link_to domain.domain, domain.url + %p + - if domain.subject + %span.label.label-gray Certificate: #{domain.subject} + - if domain.expired? + %span.label.label-danger Expired diff --git a/app/views/projects/pages/_no_domains.html.haml b/app/views/projects/pages/_no_domains.html.haml new file mode 100644 index 00000000000..7cea5f3e70b --- /dev/null +++ b/app/views/projects/pages/_no_domains.html.haml @@ -0,0 +1,7 @@ +- if can?(current_user, :update_pages, @project) + .panel.panel-default + .panel-heading + Domains + .nothing-here-block + Support for domains and certificates is disabled. + Ask your system's administrator to enable it. diff --git a/app/views/projects/pages/_use.html.haml b/app/views/projects/pages/_use.html.haml new file mode 100644 index 00000000000..9db46f0b1fc --- /dev/null +++ b/app/views/projects/pages/_use.html.haml @@ -0,0 +1,8 @@ +- unless @project.pages_deployed? + .panel.panel-info + .panel-heading + Configure pages + .panel-body + %p + Learn how to upload your static site and have it served by + GitLab by following the #{link_to "documentation on GitLab Pages", "http://doc.gitlab.com/ee/pages/README.html", target: :blank}. diff --git a/app/views/projects/pages/show.html.haml b/app/views/projects/pages/show.html.haml new file mode 100644 index 00000000000..b6595269b06 --- /dev/null +++ b/app/views/projects/pages/show.html.haml @@ -0,0 +1,26 @@ +- page_title 'Pages' +%h3.page_title + Pages + + - if can?(current_user, :update_pages, @project) && (Gitlab.config.pages.external_http || Gitlab.config.pages.external_https) + = link_to new_namespace_project_pages_domain_path(@project.namespace, @project), class: 'btn btn-new pull-right', title: 'New Domain' do + %i.fa.fa-plus + New Domain + +%p.light + With GitLab Pages you can host your static websites on GitLab. + Combined with the power of GitLab CI and the help of GitLab Runner + you can deploy static pages for your individual projects, your user or your group. + +%hr.clearfix + +- if Gitlab.config.pages.enabled + = render 'access' + = render 'use' + - if Gitlab.config.pages.external_http || Gitlab.config.pages.external_https + = render 'list' + - else + = render 'no_domains' + = render 'destroy' +- else + = render 'disabled' diff --git a/app/views/projects/pages_domains/_form.html.haml b/app/views/projects/pages_domains/_form.html.haml new file mode 100644 index 00000000000..ca1b41b140a --- /dev/null +++ b/app/views/projects/pages_domains/_form.html.haml @@ -0,0 +1,34 @@ += form_for [@project.namespace.becomes(Namespace), @project, @domain], html: { class: 'form-horizontal fieldset-form' } do |f| + - if @domain.errors.any? + #error_explanation + .alert.alert-danger + - @domain.errors.full_messages.each do |msg| + %p= msg + + .form-group + = f.label :domain, class: 'control-label' do + Domain + .col-sm-10 + = f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control' + + - if Gitlab.config.pages.external_https + .form-group + = f.label :certificate, class: 'control-label' do + Certificate (PEM) + .col-sm-10 + = f.text_area :certificate, rows: 5, class: 'form-control' + %span.help-inline Upload a certificate for your domain with all intermediates + + .form-group + = f.label :key, class: 'control-label' do + Key (PEM) + .col-sm-10 + = f.text_area :key, rows: 5, class: 'form-control' + %span.help-inline Upload a private key for your certificate + - else + .nothing-here-block + Support for custom certificates is disabled. + Ask your system's administrator to enable it. + + .form-actions + = f.submit 'Create New Domain', class: "btn btn-save" diff --git a/app/views/projects/pages_domains/new.html.haml b/app/views/projects/pages_domains/new.html.haml new file mode 100644 index 00000000000..e1477c71d06 --- /dev/null +++ b/app/views/projects/pages_domains/new.html.haml @@ -0,0 +1,6 @@ +- page_title 'New Pages Domain' +%h3.page_title + New Pages Domain +%hr.clearfix +%div + = render 'form' diff --git a/app/views/projects/pages_domains/show.html.haml b/app/views/projects/pages_domains/show.html.haml new file mode 100644 index 00000000000..52dddb052a7 --- /dev/null +++ b/app/views/projects/pages_domains/show.html.haml @@ -0,0 +1,30 @@ +- page_title "#{@domain.domain}", 'Pages Domains' + +%h3.page-title + Pages Domain + +.table-holder + %table.table + %tr + %td + Domain + %td + = link_to @domain.domain, @domain.url + %tr + %td + DNS + %td + %p + To access the domain create a new DNS record: + %pre + #{@domain.domain} CNAME #{@domain.project.namespace.path}.#{Settings.pages.host}. + %tr + %td + Certificate + %td + - if @domain.certificate_text + %pre + = @domain.certificate_text + - else + .light + missing diff --git a/app/views/projects/pipelines/index.html.haml b/app/views/projects/pipelines/index.html.haml index f776734556a..81e393d7626 100644 --- a/app/views/projects/pipelines/index.html.haml +++ b/app/views/projects/pipelines/index.html.haml @@ -36,31 +36,27 @@ = link_to ci_lint_path, class: 'btn btn-default' do %span CI Lint .content-list.pipelines{ data: { url: namespace_project_pipelines_path(@project.namespace, @project, format: :json) } } - - if @pipelines.blank? - %div - .nothing-here-block No pipelines to show - - else - .pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"), - "icon_status_canceled" => custom_icon("icon_status_canceled"), - "icon_status_running" => custom_icon("icon_status_running"), - "icon_status_skipped" => custom_icon("icon_status_skipped"), - "icon_status_created" => custom_icon("icon_status_created"), - "icon_status_pending" => custom_icon("icon_status_pending"), - "icon_status_success" => custom_icon("icon_status_success"), - "icon_status_failed" => custom_icon("icon_status_failed"), - "icon_status_warning" => custom_icon("icon_status_warning"), - "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), - "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), - "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), - "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), - "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), - "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), - "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), - "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), - "icon_play" => custom_icon("icon_play"), - "icon_timer" => custom_icon("icon_timer"), - "icon_status_manual" => custom_icon("icon_status_manual"), - } } + .pipeline-svgs{ "data" => {"commit_icon_svg" => custom_icon("icon_commit"), + "icon_status_canceled" => custom_icon("icon_status_canceled"), + "icon_status_running" => custom_icon("icon_status_running"), + "icon_status_skipped" => custom_icon("icon_status_skipped"), + "icon_status_created" => custom_icon("icon_status_created"), + "icon_status_pending" => custom_icon("icon_status_pending"), + "icon_status_success" => custom_icon("icon_status_success"), + "icon_status_failed" => custom_icon("icon_status_failed"), + "icon_status_warning" => custom_icon("icon_status_warning"), + "stage_icon_status_canceled" => custom_icon("icon_status_canceled_borderless"), + "stage_icon_status_running" => custom_icon("icon_status_running_borderless"), + "stage_icon_status_skipped" => custom_icon("icon_status_skipped_borderless"), + "stage_icon_status_created" => custom_icon("icon_status_created_borderless"), + "stage_icon_status_pending" => custom_icon("icon_status_pending_borderless"), + "stage_icon_status_success" => custom_icon("icon_status_success_borderless"), + "stage_icon_status_failed" => custom_icon("icon_status_failed_borderless"), + "stage_icon_status_warning" => custom_icon("icon_status_warning_borderless"), + "icon_play" => custom_icon("icon_play"), + "icon_timer" => custom_icon("icon_timer"), + "icon_status_manual" => custom_icon("icon_status_manual"), + } } .vue-pipelines-index diff --git a/app/views/projects/snippets/_actions.html.haml b/app/views/projects/snippets/_actions.html.haml index e2a5107a883..dde2e2b644d 100644 --- a/app/views/projects/snippets/_actions.html.haml +++ b/app/views/projects/snippets/_actions.html.haml @@ -1,3 +1,5 @@ +- return unless current_user + .hidden-xs - if can?(current_user, :update_project_snippet, @snippet) = link_to edit_namespace_project_snippet_path(@project.namespace, @project, @snippet), class: "btn btn-grouped" do diff --git a/app/views/snippets/_actions.html.haml b/app/views/snippets/_actions.html.haml index 9a9a3ff9220..855a995afa9 100644 --- a/app/views/snippets/_actions.html.haml +++ b/app/views/snippets/_actions.html.haml @@ -1,3 +1,5 @@ +- return unless current_user + .hidden-xs - if can?(current_user, :update_personal_snippet, @snippet) = link_to edit_snippet_path(@snippet), class: "btn btn-grouped" do @@ -5,29 +7,27 @@ - if can?(current_user, :admin_personal_snippet, @snippet) = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, class: "btn btn-grouped btn-inverted btn-remove", title: 'Delete Snippet' do Delete - - if current_user - = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do - New snippet + = link_to new_snippet_path, class: "btn btn-grouped btn-inverted btn-create", title: "New snippet" do + New snippet - if @snippet.submittable_as_spam? && current_user.admin? = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post, class: 'btn btn-grouped btn-spam', title: 'Submit as spam' -- if current_user - .visible-xs-block.dropdown - %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } - Options - = icon('caret-down') - .dropdown-menu.dropdown-menu-full-width - %ul +.visible-xs-block.dropdown + %button.btn.btn-default.btn-block.append-bottom-0.prepend-top-5{ data: { toggle: "dropdown" } } + Options + = icon('caret-down') + .dropdown-menu.dropdown-menu-full-width + %ul + %li + = link_to new_snippet_path, title: "New snippet" do + New snippet + - if can?(current_user, :admin_personal_snippet, @snippet) %li - = link_to new_snippet_path, title: "New snippet" do - New snippet - - if can?(current_user, :admin_personal_snippet, @snippet) - %li - = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do - Delete - - if can?(current_user, :update_personal_snippet, @snippet) - %li - = link_to edit_snippet_path(@snippet) do - Edit - - if @snippet.submittable_as_spam? && current_user.admin? - %li - = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post + = link_to snippet_path(@snippet), method: :delete, data: { confirm: "Are you sure?" }, title: 'Delete Snippet' do + Delete + - if can?(current_user, :update_personal_snippet, @snippet) + %li + = link_to edit_snippet_path(@snippet) do + Edit + - if @snippet.submittable_as_spam? && current_user.admin? + %li + = link_to 'Submit as spam', mark_as_spam_snippet_path(@snippet), method: :post diff --git a/app/workers/pages_worker.rb b/app/workers/pages_worker.rb new file mode 100644 index 00000000000..4eeb9666bb0 --- /dev/null +++ b/app/workers/pages_worker.rb @@ -0,0 +1,23 @@ +class PagesWorker + include Sidekiq::Worker + + sidekiq_options queue: :pages, retry: false + + def perform(action, *arg) + send(action, *arg) + end + + def deploy(build_id) + build = Ci::Build.find_by(id: build_id) + result = Projects::UpdatePagesService.new(build.project, build).execute + if result[:status] == :success + result = Projects::UpdatePagesConfigurationService.new(build.project).execute + end + result + end + + def remove(namespace_path, project_path) + full_path = File.join(Settings.pages.path, namespace_path, project_path) + FileUtils.rm_r(full_path, force: true) + end +end |