Commit 2cb19720 authored by Valery Sizov's avatar Valery Sizov

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee into ce-upstream

parents fbc17354 d04a47c4
......@@ -21,6 +21,7 @@ eslint-report.html
/backups/*
/config/aws.yml
/config/database.yml
/config/database_geo.yml
/config/gitlab.yml
/config/gitlab_ci.yml
/config/initializers/rack_attack.rb
......
......@@ -27,6 +27,7 @@ before_script:
- '[ "$USE_BUNDLE_INSTALL" != "true" ] || retry bundle install --without postgres production --jobs $(nproc) $FLAGS'
- retry gem install knapsack
- '[ "$SETUP_DB" != "true" ] || bundle exec rake db:drop db:create db:schema:load db:migrate add_limits_mysql'
- '[ "$SETUP_DB" != "true" ] || bundle exec rake geo:db:drop geo:db:create geo:db:schema:load geo:db:migrate'
stages:
- prepare
......
......@@ -20,6 +20,7 @@ AllCops:
- 'node_modules/**/*'
- 'db/*'
- 'db/fixtures/**/*'
- 'db/geo/*'
- 'tmp/**/*'
- 'bin/**/*'
- 'generator_templates/**/*'
......@@ -349,6 +350,7 @@ Style/MutableConstant:
Exclude:
- 'db/migrate/**/*'
- 'db/post_migrate/**/*'
- 'db/geo/migrate/**/*'
# Favor unless over if for negative conditions (or control flow or).
Style/NegatedIf:
......
Please view this file on the master branch, on stable branches it's out of date.
## 8.17.3 (2017-03-07)
- No changes.
## 8.17.2 (2017-03-01)
- No changes.
......
......@@ -2,6 +2,15 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 8.17.3 (2017-03-07)
- Fix the redirect to custom home page URL. !9518
- Fix broken migration when upgrading straight to 8.17.1. !9613
- Make projects dropdown only show projects you are a member of. !9614
- Fix creating a file in an empty repo using the API. !9632
- Don't copy tooltip when copying GFM.
- Fix cherry-picking or reverting through an MR.
## 8.17.2 (2017-03-01)
- Expire all webpack assets after 8.17.1 included a badly compiled asset. !9602
......
......@@ -51,7 +51,8 @@ $(() => {
issueLinkBase: $boardApp.dataset.issueLinkBase,
rootPath: $boardApp.dataset.rootPath,
bulkUpdatePath: $boardApp.dataset.bulkUpdatePath,
detailIssue: Store.detail
detailIssue: Store.detail,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
},
computed: {
detailIssueVisible () {
......@@ -59,6 +60,10 @@ $(() => {
},
},
created () {
if (this.milestoneTitle) {
this.state.filters.milestone_title = this.milestoneTitle;
}
gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId);
},
mounted () {
......@@ -84,7 +89,8 @@ $(() => {
gl.IssueBoardsSearch = new Vue({
el: document.getElementById('js-boards-search'),
data: {
filters: Store.state.filters
filters: Store.state.filters,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
},
mounted () {
gl.issueBoards.newListDropdownInit();
......
/* global Vue */
/* global BoardService */
const boardMilestoneSelect = require('./milestone_select');
const extraMilestones = require('../mixins/extra_milestones');
(() => {
window.gl = window.gl || {};
......@@ -7,18 +10,32 @@
const Store = gl.issueBoards.BoardsStore;
gl.issueBoards.BoardSelectorForm = Vue.extend({
props: {
milestonePath: {
type: String,
required: true,
},
},
data() {
return {
board: {
id: false,
name: '',
milestone: extraMilestones[0],
milestone_id: extraMilestones[0].id,
},
currentBoard: Store.state.currentBoard,
currentPage: Store.state.currentPage,
milestones: [],
milestoneDropdownOpen: false,
extraMilestones,
};
},
components: {
boardMilestoneSelect,
},
mounted() {
if (this.currentBoard && Object.keys(this.currentBoard).length && this.currentPage === 'edit') {
if (this.currentBoard && Object.keys(this.currentBoard).length && this.currentPage !== 'new') {
this.board = Vue.util.extend({}, this.currentBoard);
}
},
......@@ -30,13 +47,35 @@
return 'Save';
},
milestoneToggleText() {
return this.board.milestone.title || 'Milestone';
},
submitDisabled() {
if (this.currentPage !== 'milestone') {
return this.board.name === '';
}
return false;
},
},
methods: {
loadMilestones() {
this.milestoneDropdownOpen = !this.milestoneDropdownOpen;
BoardService.loadMilestones.call(this);
},
submit() {
gl.boardService.createBoard(this.board)
.then(() => {
if (this.currentBoard && this.currentPage === 'edit') {
if (this.currentBoard && this.currentPage !== 'new') {
this.currentBoard.name = this.board.name;
if (this.board.milestone) {
this.currentBoard.milestone_id = this.board.milestone_id;
this.currentBoard.milestone = this.board.milestone;
Store.state.filters.milestone_title = this.currentBoard.milestone_id ?
this.currentBoard.milestone.title : null;
}
}
// Enable the button thanks to our jQuery disabling it
......@@ -50,6 +89,13 @@
cancel() {
Store.state.currentPage = '';
},
selectMilestone(milestone) {
this.milestoneDropdownOpen = false;
this.board.milestone_id = milestone.id;
this.board.milestone = {
title: milestone.title,
};
},
},
});
})();
......@@ -15,8 +15,14 @@ require('./board_new_form');
'board-selector-form': gl.issueBoards.BoardSelectorForm,
},
props: {
currentBoard: Object,
endpoint: String,
currentBoard: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
},
data() {
return {
......@@ -36,6 +42,12 @@ require('./board_new_form');
this.loadBoards(false);
}
},
board: {
handler() {
this.updateMilestoneFilterDropdown();
},
deep: true,
},
},
computed: {
currentPage() {
......@@ -51,7 +63,7 @@ require('./board_new_form');
return this.boards.length > 1;
},
title() {
if (this.currentPage === 'edit') {
if (this.currentPage === 'edit' || this.currentPage === 'milestone') {
return 'Edit board';
} else if (this.currentPage === 'new') {
return 'Create new board';
......@@ -82,9 +94,33 @@ require('./board_new_form');
});
}
},
updateMilestoneFilterDropdown() {
const $milestoneDropdownToggle = $('.js-milestone-select');
const glDropdown = $milestoneDropdownToggle.data('glDropdown');
const $milestoneDropdown = $('.dropdown-menu-milestone');
const hideElements = this.board.milestone === undefined || this.board.milestone_id === null;
$('#milestone_title').val(this.board.milestone ? this.board.milestone.title : '');
if (glDropdown.fullData) {
glDropdown.parseData(glDropdown.fullData);
}
$milestoneDropdown.find('.dropdown-input, .dropdown-footer-list')
.toggle(hideElements);
$milestoneDropdown.find('.js-milestone-footer-content').toggle(!hideElements);
$milestoneDropdown.find('.dropdown-content li').show()
.filter((i, el) => $(el).find('.is-active').length === 0)
.toggle(hideElements);
$('.js-milestone-select .dropdown-toggle-text')
.text(hideElements ? 'Milestone' : this.board.milestone.title)
.toggleClass('is-default', hideElements);
},
},
created() {
this.state.currentBoard = this.currentBoard;
this.updateMilestoneFilterDropdown();
},
});
})();
/* global BoardService */
/* global Vue */
const extraMilestones = require('../mixins/extra_milestones');
module.exports = {
props: {
board: {
type: Object,
required: true,
},
milestonePath: {
type: String,
required: true,
},
selectMilestone: {
type: Function,
required: true,
},
},
data() {
return {
loading: false,
milestones: [],
extraMilestones,
};
},
mounted() {
BoardService.loadMilestones.call(this);
},
template: `
<div>
<div class="text-center">
<i
v-if="loading"
class="fa fa-spinner fa-spin"></i>
</div>
<ul
class="board-milestone-list"
v-if="!loading">
<li v-for="milestone in extraMilestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
<li class="divider"></li>
<li v-for="milestone in milestones">
<a
href="#"
@click.prevent.stop="selectMilestone(milestone)">
<i
class="fa fa-check"
v-if="board.milestone_id === milestone.id"></i>
{{ milestone.title }}
</a>
</li>
</ul>
</div>
`,
};
module.exports = [
{
id: null,
title: 'Any Milestone',
},
{
id: -2,
title: 'Upcoming',
},
];
......@@ -102,6 +102,16 @@ class BoardService {
return this.issues.bulkUpdate(data);
}
static loadMilestones(path) {
this.loading = true;
return this.$http.get(this.milestonePath)
.then((res) => {
this.milestones = res.json();
this.loading = false;
});
}
}
window.BoardService = BoardService;
import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */
/* global UsernameValidator */
/* global ActiveTabMemoizer */
......@@ -296,7 +297,7 @@ const UserCallout = require('./user_callout');
case 'admin:emails:show':
new AdminEmailSelect();
break;
case 'projects:protected_branches:index':
case 'projects:repository:show':
new gl.ProtectedBranchCreate();
new gl.ProtectedBranchEditList();
break;
......@@ -307,6 +308,8 @@ const UserCallout = require('./user_callout');
case 'ci:lints:show':
new gl.CILintEditor();
break;
case 'projects:environments:metrics':
new PrometheusGraph();
case 'users:show':
new UserCallout();
break;
......@@ -398,7 +401,7 @@ const UserCallout = require('./user_callout');
case 'builds':
case 'hooks':
case 'services':
case 'protected_branches':
case 'repository':
shortcut_handler = new ShortcutsNavigation();
}
}
......
......@@ -37,11 +37,14 @@ require('../window')(function(w){
}
}
self.hook.list[config.method].call(self.hook.list, data);
if (!self.destroyed) {
self.hook.list[config.method].call(self.hook.list, data);
}
},
init: function init(hook) {
var self = this;
self.destroyed = false;
self.cache = self.cache || {};
var config = hook.config.droplabAjax;
this.hook = hook;
......@@ -79,6 +82,7 @@ require('../window')(function(w){
destroy: function() {
var dynamicList = this.hook.list.list.querySelector('[data-dynamic]');
this.destroyed = true;
if (this.listTemplate && dynamicList) {
dynamicList.outerHTML = this.listTemplate;
}
......
......@@ -28,6 +28,23 @@ require('./filtered_search_dropdown');
const tag = selected.querySelector('.js-filter-tag').innerText.trim();
if (tag.length) {
// Get previous input values in the input field and convert them into visual tokens
const previousInputValues = this.input.value.split(' ');
const searchTerms = [];
previousInputValues.forEach((value, index) => {
searchTerms.push(value);
if (index === previousInputValues.length - 1
&& token.indexOf(value.toLowerCase()) !== -1) {
searchTerms.pop();
}
});
if (searchTerms.length > 0) {
gl.FilteredSearchVisualTokens.addSearchVisualToken(searchTerms.join(' '));
}
gl.FilteredSearchDropdownManager.addWordToInput(token.replace(':', ''));
}
this.dismissDropdown();
......@@ -39,7 +56,7 @@ require('./filtered_search_dropdown');
renderContent() {
const dropdownData = [];
[].forEach.call(this.input.parentElement.querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
const { icon, hint, tag } = dropdownMenu.dataset;
if (icon && hint && tag) {
dropdownData.push({
......
......@@ -39,7 +39,12 @@ require('./filtered_search_dropdown');
getSearchInput() {
const query = gl.DropdownUtils.getSearchInput(this.input);
const { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
let value = lastToken.value || '';
let value = lastToken || '';
if (value[0] === '@') {
value = value.slice(1);
}
// Removes the first character if it is a quotation so that we can search
// with multiple words
......
......@@ -22,38 +22,40 @@
static filterWithSymbol(filterSymbol, input, item) {
const updatedItem = item;
const query = gl.DropdownUtils.getSearchInput(input);
const { lastToken, searchToken } = gl.FilteredSearchTokenizer.processTokens(query);
const searchInput = gl.DropdownUtils.getSearchInput(input);
if (lastToken !== searchToken) {
const title = updatedItem.title.toLowerCase();
let value = lastToken.value.toLowerCase();
const title = updatedItem.title.toLowerCase();
let value = searchInput.toLowerCase();
let symbol = '';
// Removes the first character if it is a quotation so that we can search
// with multiple words
if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) {
value = value.slice(1);
}
// Eg. filterSymbol = ~ for labels
const matchWithoutSymbol = lastToken.symbol === filterSymbol && title.indexOf(value) !== -1;
const match = title.indexOf(`${lastToken.symbol}${value}`) !== -1;
// Remove the symbol for filter
if (value[0] === filterSymbol) {
symbol = value[0];
value = value.slice(1);
}
updatedItem.droplab_hidden = !match && !matchWithoutSymbol;
} else {
updatedItem.droplab_hidden = false;
// Removes the first character if it is a quotation so that we can search
// with multiple words
if ((value[0] === '"' || value[0] === '\'') && title.indexOf(' ') !== -1) {
value = value.slice(1);
}
// Eg. filterSymbol = ~ for labels
const matchWithoutSymbol = symbol === filterSymbol && title.indexOf(value) !== -1;
const match = title.indexOf(`${symbol}${value}`) !== -1;
updatedItem.droplab_hidden = !match && !matchWithoutSymbol;
return updatedItem;
}
static filterHint(input, item) {
const updatedItem = item;
const query = gl.DropdownUtils.getSearchInput(input);
let { lastToken } = gl.FilteredSearchTokenizer.processTokens(query);
const searchInput = gl.DropdownUtils.getSearchInput(input);
let { lastToken } = gl.FilteredSearchTokenizer.processTokens(searchInput);
lastToken = lastToken.key || lastToken || '';
if (!lastToken || query.split('').last() === ' ') {
if (!lastToken || searchInput.split('').last() === ' ') {
updatedItem.droplab_hidden = false;
} else if (lastToken) {
const split = lastToken.split(':');
......@@ -70,13 +72,40 @@
const dataValue = selected.getAttribute('data-value');
if (dataValue) {
gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue);
gl.FilteredSearchDropdownManager.addWordToInput(filter, dataValue, true);
}
// Return boolean based on whether it was set
return dataValue !== null;
}
static getSearchQuery() {
const tokensContainer = document.querySelector('.tokens-container');
const values = [];
[].forEach.call(tokensContainer.querySelectorAll('.js-visual-token'), (token) => {
const name = token.querySelector('.name');
const value = token.querySelector('.value');
const symbol = value && value.dataset.symbol ? value.dataset.symbol : '';
let valueText = '';
if (value && value.innerText) {
valueText = value.innerText;
}
if (token.className.indexOf('filtered-search-token') !== -1) {
values.push(`${name.innerText.toLowerCase()}:${symbol}${valueText}`);
} else {
values.push(name.innerText);
}
});
const input = document.querySelector('.filtered-search');
values.push(input && input.value);
return values.join(' ');
}
static getSearchInput(filteredSearchInput) {
const inputValue = filteredSearchInput.value;
const { right } = gl.DropdownUtils.getInputSelectionPosition(filteredSearchInput);
......
......@@ -6,5 +6,7 @@ require('./filtered_search_dropdown_manager');
require('./filtered_search_dropdown');
require('./filtered_search_manager');
require('./filtered_search_token_keys');
require('./filtered_search_token_keys_with_weights');
require('./filtered_search_tokenizer');
require('./filtered_search_visual_tokens');
require('./filtered_search_token_keys_with_weights');
......@@ -35,7 +35,7 @@
if (!dataValueSet) {
const value = getValueFunction(selected);
gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value);
gl.FilteredSearchDropdownManager.addWordToInput(this.filter, value, true);
}
this.dismissDropdown();
......
......@@ -70,35 +70,15 @@
}
}
static addWordToInput(tokenName, tokenValue = '') {
static addWordToInput(tokenName, tokenValue = '', clicked = false) {
const input = document.querySelector('.filtered-search');
const inputValue = input.value;
const word = `${tokenName}:${tokenValue}`;
// Get the string to replace
let newCaretPosition = input.selectionStart;
const { left, right } = gl.DropdownUtils.getInputSelectionPosition(input);
gl.FilteredSearchVisualTokens.addFilterVisualToken(tokenName, tokenValue);
input.value = '';
input.value = `${inputValue.substr(0, left)}${word}${inputValue.substr(right)}`;
// If we have added a tokenValue at the end of the input,
// add a space and set selection to the end
if (right >= inputValue.length && tokenValue !== '') {
input.value += ' ';
newCaretPosition = input.value.length;
if (clicked) {
gl.FilteredSearchVisualTokens.moveInputToTheRight();
}
gl.FilteredSearchDropdownManager.updateInputCaretPosition(newCaretPosition, input);
}
static updateInputCaretPosition(selectionStart, input) {
// Reset the position
// Sometimes can end up at end of input
input.setSelectionRange(selectionStart, selectionStart);
const { right } = gl.DropdownUtils.getInputSelectionPosition(input);
input.setSelectionRange(right, right);
}
updateCurrentDropdownOffset() {
......@@ -106,19 +86,14 @@
}
updateDropdownOffset(key) {
if (!this.font) {
this.font = window.getComputedStyle(this.filteredSearchInput).font;
}
const input = this.filteredSearchInput;
const inputText = input.value.slice(0, input.selectionStart);
const filterIconPadding = 27;
let offset = gl.text.getTextWidth(inputText, this.font) + filterIconPadding;
// Always align dropdown with the input field
let offset = this.filteredSearchInput.getBoundingClientRect().left - document.querySelector('.scroll-container').getBoundingClientRect().left;
const currentDropdownWidth = this.mapping[key].element.clientWidth === 0 ? 200 :
this.mapping[key].element.clientWidth;
const offsetMaxWidth = this.filteredSearchInput.clientWidth - currentDropdownWidth;
const maxInputWidth = 240;
const currentDropdownWidth = this.mapping[key].element.clientWidth || maxInputWidth;
// Make sure offset never exceeds the input container
const offsetMaxWidth = document.querySelector('.scroll-container').clientWidth - currentDropdownWidth;
if (offsetMaxWidth < offset) {
offset = offsetMaxWidth;
}
......@@ -176,8 +151,8 @@
}
setDropdown() {
const { lastToken, searchToken } = this.tokenizer
.processTokens(gl.DropdownUtils.getSearchInput(this.filteredSearchInput));
const query = gl.DropdownUtils.getSearchQuery();
const { lastToken, searchToken } = this.tokenizer.processTokens(query);
if (this.currentDropdown) {
this.updateCurrentDropdownOffset();
......
class FilteredSearchVisualTokens {
static getLastVisualTokenBeforeInput() {
const inputLi = document.querySelector('.input-token');
const lastVisualToken = inputLi && inputLi.previousElementSibling;
return {
lastVisualToken,
isLastVisualTokenValid: lastVisualToken === null || lastVisualToken.className.indexOf('filtered-search-term') !== -1 || (lastVisualToken && lastVisualToken.querySelector('.value') !== null),
};
}
static unselectTokens() {
const otherTokens = document.querySelectorAll('.js-visual-token .selectable.selected');
[].forEach.call(otherTokens, t => t.classList.remove('selected'));
}
static selectToken(tokenButton) {
const selected = tokenButton.classList.contains('selected');
FilteredSearchVisualTokens.unselectTokens();
if (!selected) {
tokenButton.classList.add('selected');
}
}
static removeSelectedToken() {
const selected = document.querySelector('.js-visual-token .selected');
if (selected) {
const li = selected.closest('.js-visual-token');
li.parentElement.removeChild(li);
}
}
static createVisualTokenElementHTML() {
return `
<div class="selectable" role="button">
<div class="name"></div>
<div class="value"></div>
</div>
`;
}
static addVisualTokenElement(name, value, isSearchTerm) {
const li = document.createElement('li');
li.classList.add('js-visual-token');
li.classList.add(isSearchTerm ? 'filtered-search-term' : 'filtered-search-token');
if (value) {
li.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
li.querySelector('.value').innerText = value;
} else {
li.innerHTML = '<div class="name"></div>';
}
li.querySelector('.name').innerText = name;
const tokensContainer = document.querySelector('.tokens-container');
const input = document.querySelector('.filtered-search');
tokensContainer.insertBefore(li, input.parentElement);
}
static addValueToPreviousVisualTokenElement(value) {
const { lastVisualToken, isLastVisualTokenValid } =
FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!isLastVisualTokenValid && lastVisualToken.classList.contains('filtered-search-token')) {
const name = FilteredSearchVisualTokens.getLastTokenPartial();
lastVisualToken.innerHTML = FilteredSearchVisualTokens.createVisualTokenElementHTML();
lastVisualToken.querySelector('.name').innerText = name;
lastVisualToken.querySelector('.value').innerText = value;
}
}
static addFilterVisualToken(tokenName, tokenValue) {
const { lastVisualToken, isLastVisualTokenValid }
= FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
const addVisualTokenElement = FilteredSearchVisualTokens.addVisualTokenElement;
if (isLastVisualTokenValid) {
addVisualTokenElement(tokenName, tokenValue);
} else {
const previousTokenName = lastVisualToken.querySelector('.name').innerText;
const tokensContainer = document.querySelector('.tokens-container');
tokensContainer.removeChild(lastVisualToken);
const value = tokenValue || tokenName;
addVisualTokenElement(previousTokenName, value);
}
}
static addSearchVisualToken(searchTerm) {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken && lastVisualToken.classList.contains('filtered-search-term')) {
lastVisualToken.querySelector('.name').innerText += ` ${searchTerm}`;
} else {
FilteredSearchVisualTokens.addVisualTokenElement(searchTerm, null, true);
}
}
static getLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!lastVisualToken) return '';
const value = lastVisualToken.querySelector('.value');
const name = lastVisualToken.querySelector('.name');
const valueText = value ? value.innerText : '';
const nameText = name ? name.innerText : '';
return valueText || nameText;
}
static removeLastTokenPartial() {
const { lastVisualToken } = FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (lastVisualToken) {
const value = lastVisualToken.querySelector('.value');
if (value) {
const button = lastVisualToken.querySelector('.selectable');
button.removeChild(value);
lastVisualToken.innerHTML = button.innerHTML;
} else {
lastVisualToken.closest('.tokens-container').removeChild(lastVisualToken);
}
}
}
static tokenizeInput() {
const input = document.querySelector('.filtered-search');
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (input.value) {
if (isLastVisualTokenValid) {
gl.FilteredSearchVisualTokens.addSearchVisualToken(input.value);
} else {
FilteredSearchVisualTokens.addValueToPreviousVisualTokenElement(input.value);
}
input.value = '';
}
}
static editToken(token) {
const input = document.querySelector('.filtered-search');
FilteredSearchVisualTokens.tokenizeInput();
// Replace token with input field
const tokenContainer = token.parentElement;
const inputLi = input.parentElement;
tokenContainer.replaceChild(inputLi, token);
const name = token.querySelector('.name');
const value = token.querySelector('.value');
if (token.classList.contains('filtered-search-token')) {
FilteredSearchVisualTokens.addFilterVisualToken(name.innerText);
input.value = value.innerText;
} else {
// token is a search term
input.value = name.innerText;
}
// Opens dropdown
const inputEvent = new Event('input');
input.dispatchEvent(inputEvent);
// Adds cursor to input
input.focus();
}
static moveInputToTheRight() {
const input = document.querySelector('.filtered-search');
const inputLi = input.parentElement;
const tokenContainer = document.querySelector('.tokens-container');
if (!tokenContainer.lastElementChild.isEqualNode(inputLi)) {
FilteredSearchVisualTokens.tokenizeInput();
const { isLastVisualTokenValid } =
gl.FilteredSearchVisualTokens.getLastVisualTokenBeforeInput();
if (!isLastVisualTokenValid) {
const lastPartial = gl.FilteredSearchVisualTokens.getLastTokenPartial();
gl.FilteredSearchVisualTokens.removeLastTokenPartial();
gl.FilteredSearchVisualTokens.addSearchVisualToken(lastPartial);
}
tokenContainer.removeChild(inputLi);
tokenContainer.appendChild(inputLi);
}
}
}
window.gl = window.gl || {};
gl.FilteredSearchVisualTokens = FilteredSearchVisualTokens;
......@@ -532,7 +532,20 @@
};
GitLabDropdown.prototype.renderItem = function(data, group, index) {
var field, fieldName, html, selected, text, url, value;
var field, fieldName, html, selected, text, url, value, rowHidden;
if (!this.options.renderRow) {
value = this.options.id ? this.options.id(data) : data.id;
if (value) {
value = value.toString().replace(/'/g, '\\\'');
}
}
// Hide element
if (this.options.hideRow && this.options.hideRow(value)) {
rowHidden = true;
}
if (group == null) {
group = false;
}
......@@ -541,7 +554,12 @@
index = false;
}
html = document.createElement('li');
if (data === 'divider' || data === 'separator') {
if (rowHidden) {
html.style.display = 'none';
}
if ((data === 'divider' || data === 'separator')) {
html.className = data;
return html;
}
......@@ -556,11 +574,8 @@
html = this.options.renderRow.call(this.options, data, this);
} else {
if (!selected) {
value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName;
if (value) { value = value.toString().replace(/'/g, '\\\''); }
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
if (field.length) {
selected = true;
......
/* eslint-disable no-new */
$(() => {
class ExportCSVModal {
constructor() {
this.$modal = $('.issues-export-modal');
this.$downloadBtn = $('.csv_download_link');
this.$closeBtn = $('.modal-header .close');
this.init();
}
init() {
this.$modal.modal({ show: false });
this.$downloadBtn.on('click', () => this.$modal.modal('show'));
this.$closeBtn.on('click', () => this.$modal.modal('hide'));
}
}
new ExportCSVModal();
});
......@@ -87,6 +87,11 @@
},
selectable: true,
toggleLabel: function(selected, el, e) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && !selected) {
return gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
if (selected && 'id' in selected && $(el).hasClass('is-active')) {
return selected.title;
} else {
......@@ -114,8 +119,25 @@
return $value.css('display', '');
},
vue: $dropdown.hasClass('js-issue-board-sidebar'),
hideRow: function(milestone) {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone) {
return milestone !== gl.issueBoards.BoardsStore.state.currentBoard.milestone.title;
}
return false;
},
isSelectable: function() {
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar') &&
!$dropdown.closest('.add-issues-modal').length && gl.issueBoards.BoardsStore.state.currentBoard.milestone_id) {
return false;
}
return true;
},
clicked: function(selected, $el, e) {
var data, isIssueIndex, isMRIndex, page, boardsStore;
if (!selected) return;
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = (page === page && page === 'projects:merge_requests:index');
......
This diff is collapsed.
......@@ -9,7 +9,7 @@
@media (min-width: $screen-sm-min) {
.issues-filters,
.issues_bulk_update {
.dropdown-menu-toggle {
.dropdown-menu-toggle:not(.wide) {
width: 132px;
}
}
......@@ -44,6 +44,89 @@
-webkit-flex-direction: column;
flex-direction: column;
}
.tokens-container {
display: -webkit-flex;
display: flex;
flex: 1;
-webkit-flex: 1;
padding-left: 30px;
position: relative;
margin-bottom: 0;
}
.input-token {
flex: 1;
-webkit-flex: 1;
}
.filtered-search-token + .input-token:not(:last-child) {
max-width: 200px;
}
}
.filtered-search-token,
.filtered-search-term {
display: -webkit-flex;
display: flex;
margin-top: 5px;
margin-bottom: 5px;
.selectable {
display: -webkit-flex;
display: flex;
}
.name,
.value {
display: inline-block;
padding: 2px 7px;
}
.name {
background-color: $filter-name-resting-color;
color: $filter-name-text-color;
border-radius: 2px 0 0 2px;
margin-right: 1px;
text-transform: capitalize;
}
.value {
background-color: $white-normal;
color: $filter-value-text-color;
border-radius: 0 2px 2px 0;
margin-right: 5px;
}
.selected {
.name {
background-color: $filter-name-selected-color;
}
.value {
background-color: $filter-value-selected-color;
}
}
}
.filtered-search-term {
.name {
background-color: inherit;
color: $black;
text-transform: none;
}
.selectable {
cursor: text;
}
}
.scroll-container {
display: -webkit-flex;
display: flex;
overflow-x: scroll;
white-space: nowrap;
width: 100%;
}
.filtered-search-input-container {
......@@ -51,6 +134,9 @@
display: flex;
position: relative;
width: 100%;
border: 1px solid $border-color;
background-color: $white-light;
max-width: 87%;
@media (max-width: $screen-xs-min) {
-webkit-flex: 1 1 100%;
......@@ -67,12 +153,22 @@
}
.form-control {
padding-left: 25px;
position: relative;
min-width: 200px;
padding-left: 0;
padding-right: 25px;
border-color: transparent;
&:focus ~ .fa-filter {
color: $common-gray-dark;
}
&:focus,
&:hover {
outline: none;
border-color: transparent;
box-shadow: none;
}
}
.fa-filter {
......@@ -89,12 +185,13 @@
.clear-search {
width: 35px;
background-color: transparent;
background-color: $white-light;
border: none;
position: absolute;
right: 0;
height: 100%;
outline: none;
z-index: 1;
&:hover .fa-times {
color: $common-gray-dark;
......
......@@ -543,3 +543,12 @@ Pipeline Graph
$stage-hover-bg: #eaf3fc;
$stage-hover-border: #d1e7fc;
$action-icon-color: #d6d6d6;
/*
Filtered Search
*/
$filter-name-resting-color: #f8f8f8;
$filter-name-text-color: rgba(0, 0, 0, 0.55);
$filter-value-text-color: rgba(0, 0, 0, 0.85);
$filter-name-selected-color: #ebebeb;
$filter-value-selected-color: #d7d7d7;
......@@ -528,12 +528,6 @@
}
.boards-switcher {
padding-right: 10px;
margin-right: 10px;
border-right: 1px solid $white-dark;
}
.modal-filters {
display: flex;
......@@ -554,3 +548,39 @@
}
}
}
.boards-switcher {
padding-right: 10px;
margin-right: 10px;
border-right: 1px solid $white-dark;
}
.board-milestone-list {
> li {
padding-left: 0;
padding-right: 0;
}
a {
padding-left: 25px;
}
.fa-check {
margin-left: -18px;
}
}
.board-inner-milestone-dropdown {
margin-top: 10px;
.dropdown-menu {
top: 60px;
min-width: 100%;
}
}
.board-milestone-footer-content {
padding-left: 12px;
padding-right: 12px;
color: $gl-gray-dark;
}
......@@ -278,3 +278,71 @@
font-size: 20px;
}
}
.prometheus-graph {
text {
fill: $stat-graph-axis-fill;
}
}
.x-axis path,
.y-axis path,
.label-x-axis-line,
.label-y-axis-line {
fill: none;
stroke-width: 1;
shape-rendering: crispEdges;
}
.x-axis path,
.y-axis path {
stroke: $stat-graph-axis-fill;
}
.label-x-axis-line,
.label-y-axis-line {
stroke: $border-color;
}
.y-axis {
line {
stroke: $stat-graph-axis-fill;
stroke-width: 1;
}
}
.metric-area {
opacity: 0.8;
}
.prometheus-graph-overlay {
fill: none;
opacity: 0.0;
pointer-events: all;
}
.rect-text-metric {
fill: $white-light;
stroke-width: 1;
stroke: $black;
}
.rect-axis-text {
fill: $white-light;
}
.text-metric,
.text-median-metric,
.text-metric-usage,
.text-metric-date {
fill: $black;
}
.text-metric-date {
font-weight: 200;
}
.selected-metric-line {
stroke: $black;
stroke-width: 1;
}
.geo-node-icon-healthy {
color: $gl-success;
}
.geo-node-icon-unhealthy {
color: $gl-danger;
}
.geo-node-icon-disabled {
color: $gray-darkest;
}
......@@ -129,6 +129,21 @@ ul.related-merge-requests > li {
padding-bottom: 37px;
}
.issues-export-modal {
.export-svg-container {
height: 56px;
padding: 10px 10px 0;
}
svg {
height: 100%;
}
.export-checkmark {
color: $green-light;
}
}
.issue-email-modal-btn {
padding: 0;
color: $gl-link-color;
......
......@@ -174,7 +174,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:repository_size_limit,
:shared_runners_minutes,
:usage_ping_enabled,
:minimum_mirror_sync_time
:minimum_mirror_sync_time,
:geo_status_timeout
]
end
end
class Admin::GeoNodesController < Admin::ApplicationController
before_action :check_license, except: [:index, :destroy]
before_action :load_node, only: [:destroy, :repair, :backfill_repositories]
before_action :load_node, only: [:destroy, :repair, :toggle]
def index
@nodes = GeoNode.all
# Ensure all nodes are using their Presenter
@nodes = GeoNode.all.map(&:present)
@node = GeoNode.new
unless Gitlab::Geo.license_allows?
......@@ -40,14 +41,20 @@ class Admin::GeoNodesController < Admin::ApplicationController
redirect_to admin_geo_nodes_path
end
def backfill_repositories
def toggle
if @node.primary?
redirect_to admin_geo_nodes_path, notice: 'This is the primary node. Please run this action with a secondary node.'
flash[:alert] = "Primary node can't be disabled."
else
@node.backfill_repositories
redirect_to admin_geo_nodes_path, notice: 'Backfill scheduled successfully.'
if @node.toggle!(:enabled)
new_status = @node.enabled? ? 'enabled' : 'disabled'
flash[:notice] = "Node #{@node.url} was successfully #{new_status}."
else
action = @node.enabled? ? 'disabling' : 'enabling'
flash[:alert] = "There was a problem #{action} node #{@node.url}."
end
end
redirect_to admin_geo_nodes_path
end
private
......
class Admin::HealthCheckController < Admin::ApplicationController
def show
@errors = HealthCheck::Utils.process_checks(['standard'])
checks = ['standard']
checks << 'geo' if Gitlab::Geo.secondary?
@errors = HealthCheck::Utils.process_checks(checks)
end
end
module RepositorySettingsRedirect
extend ActiveSupport::Concern
def redirect_to_repository_settings(project)
redirect_to namespace_project_settings_repository_path(project.namespace, project)
end
end
......@@ -75,7 +75,7 @@ class Projects::BoardsController < Projects::ApplicationController
end
def board_params
params.require(:board).permit(:name)
params.require(:board).permit(:name, :milestone_id)
end
def find_board
......@@ -83,6 +83,11 @@ class Projects::BoardsController < Projects::ApplicationController
end
def serialize_as_json(resource)
resource.as_json(only: [:id, :name])
resource.as_json(
only: [:id, :name],
include: {
milestone: { only: [:id, :title] }
}
)
end
end
class Projects::DeployKeysController < Projects::ApplicationController
include RepositorySettingsRedirect
respond_to :html
# Authorize
......@@ -7,25 +8,22 @@ class Projects::DeployKeysController < Projects::ApplicationController
layout "project_settings"
def index
@key = DeployKey.new
set_index_vars
redirect_to_repository_settings(@project)
end
def new
redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
redirect_to_repository_settings(@project)
end
def create
@key = DeployKey.new(deploy_key_params.merge(user: current_user))
set_index_vars
if @key.valid? && @project.deploy_keys << @key
log_audit_event(@key.title, action: :create)
redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
unless @key.valid? && @project.deploy_keys << @key
flash[:alert] = @key.errors.full_messages.join(', ').html_safe
else
render "index"
log_audit_event(@key.title, action: :create)
end
redirect_to_repository_settings(@project)
end
def enable
......@@ -33,7 +31,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
Projects::EnableDeployKeyService.new(@project, current_user, params).execute
log_audit_event(@key.title, action: :create)
redirect_to namespace_project_deploy_keys_path(@project.namespace, @project)
redirect_to_repository_settings(@project)
end
def disable
......@@ -41,23 +39,11 @@ class Projects::DeployKeysController < Projects::ApplicationController
@project.deploy_keys_projects.find_by(deploy_key_id: params[:id]).destroy
log_audit_event(@key.title, action: :destroy)
redirect_back_or_default(default: { action: 'index' })
redirect_to_repository_settings(@project)
end
protected
def set_index_vars
@enabled_keys ||= @project.deploy_keys
@available_keys ||= current_user.accessible_deploy_keys - @enabled_keys
@available_project_keys ||= current_user.project_deploy_keys - @enabled_keys
@available_public_keys ||= DeployKey.are_public - @enabled_keys
# Public keys that are already used by another accessible project are already
# in @available_project_keys.
@available_public_keys -= @available_project_keys
end
def deploy_key_params
params.require(:deploy_key).permit(:key, :title, :can_push)
end
......
......@@ -5,7 +5,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController
before_action :authorize_create_deployment!, only: [:stop]
before_action :authorize_update_environment!, only: [:edit, :update]
before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :status]
before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics, :status]
before_action :verify_api_request!, only: :terminal_websocket_authorize
def index
......@@ -109,6 +109,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController
end
end
def metrics
# Currently, this acts as a hint to load the metrics details into the cache
# if they aren't there already
@metrics = environment.metrics || {}
respond_to do |format|
format.html
format.json do
render json: @metrics, status: @metrics.any? ? :ok : :no_content
end
end
end
# The rollout status of an enviroment
def status
unless @environment.deployment_service_ready?
......
......@@ -6,6 +6,8 @@ class Projects::IssuesController < Projects::ApplicationController
include IssuableCollections
include SpammableActions
prepend_before_action :authenticate_user!, only: [:export_csv]
before_action :redirect_to_external_issue_tracker, only: [:index, :new]
before_action :module_enabled
before_action :issue, only: [:edit, :update, :show, :referenced_merge_requests,
......@@ -25,6 +27,7 @@ class Projects::IssuesController < Projects::ApplicationController
def index
@collection_type = "Issue"
@issues = issues_collection
@issues = @issues.page(params[:page])
@issuable_meta_data = issuable_meta_data(@issues, @collection_type)
......@@ -142,6 +145,14 @@ class Projects::IssuesController < Projects::ApplicationController
render_conflict_response
end
def export_csv
csv_params = filter_params.permit(IssuableFinder::VALID_PARAMS)
ExportCsvWorker.perform_async(@current_user.id, @project.id, csv_params)
index_path = namespace_project_issues_path(@project.namespace, @project)
redirect_to(index_path, notice: "Your CSV export has started. It will be emailed to #{current_user.notification_email} when complete.")
end
def referenced_merge_requests
@merge_requests = @issue.referenced_merge_requests(current_user)
@closed_by_merge_requests = @issue.closed_by_merge_requests(current_user)
......
class Projects::MirrorsController < Projects::ApplicationController
include RepositorySettingsRedirect
# Authorize
before_action :authorize_admin_project!, except: [:update_now]
before_action :authorize_push_code!, only: [:update_now]
before_action :remote_mirror, only: [:show, :update]
before_action :remote_mirror, only: [:update]
layout "project_settings"
def show
redirect_to_repository_settings(@project)
end
def update
......@@ -20,11 +22,11 @@ class Projects::MirrorsController < Projects::ApplicationController
else
flash[:notice] = "Mirroring settings were successfully updated."
end
redirect_to namespace_project_mirror_path(@project.namespace, @project)
else
render :show
flash[:alert] = @project.errors.full_messages.join(', ').html_safe
end
redirect_to_repository_settings(@project)
end
def update_now
......@@ -35,8 +37,7 @@ class Projects::MirrorsController < Projects::ApplicationController
@project.update_mirror
flash[:notice] = "The repository is being updated..."
end
redirect_back_or_default(default: namespace_project_path(@project.namespace, @project))
redirect_to_repository_settings(@project)
end
private
......
class Projects::ProtectedBranchesController < Projects::ApplicationController
include RepositorySettingsRedirect
# Authorize
before_action :require_non_empty_project
before_action :authorize_admin_project!
before_action :load_protected_branch, only: [:show, :update, :destroy]
before_action :load_protected_branches, only: [:index]
layout "project_settings"
def index
@protected_branch = @project.protected_branches.new
load_gon_index
redirect_to_repository_settings(@project)
end
def create
@protected_branch = ::ProtectedBranches::CreateService.new(@project, current_user, protected_branch_params).execute
if @protected_branch.persisted?
redirect_to namespace_project_protected_branches_path(@project.namespace, @project)
else
load_protected_branches
load_gon_index
render :index
unless @protected_branch.persisted?
flash[:alert] = @protected_branches.errors.full_messages.join(', ').html_safe
end
redirect_to_repository_settings(@project)
end
def show
......@@ -46,7 +41,7 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
@protected_branch.destroy
respond_to do |format|
format.html { redirect_to namespace_project_protected_branches_path }
format.html { redirect_to_repository_settings(@project) }
format.js { head :ok }
end
end
......@@ -66,23 +61,4 @@ class Projects::ProtectedBranchesController < Projects::ApplicationController
def load_protected_branches
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
end
def access_levels_options
{
push_access_levels: {
roles: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
},
merge_access_levels: {
roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
},
selected_merge_access_levels: @protected_branch.merge_access_levels.map { |access_level| access_level.user_id || access_level.access_level },
selected_push_access_levels: @protected_branch.push_access_levels.map { |access_level| access_level.user_id || access_level.access_level }
}
end
def load_gon_index
params = { open_branches: @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } } }
params[:current_project_id] = @project.id if @project
gon.push(params.merge(access_levels_options))
end
end
class Projects::PushRulesController < Projects::ApplicationController
include RepositorySettingsRedirect
# Authorize
before_action :authorize_admin_project!
......@@ -17,10 +19,11 @@ class Projects::PushRulesController < Projects::ApplicationController
@push_rule.update_attributes(push_rule_params)
if @push_rule.valid?
redirect_to namespace_project_push_rules_path(@project.namespace, @project), notice: 'Push Rules updated successfully.'
flash[:notice] = 'Push Rules updated successfully.'
else
render :index
flash[:alert] = @push_rule.errors.full_messages.join(', ').html_safe
end
redirect_to_repository_settings(@project)
end
private
......
module Projects
module Settings
class RepositoryController < Projects::ApplicationController
before_action :authorize_admin_project!
before_action :push_rule, only: [:show]
before_action :remote_mirror, only: [:show]
def show
@deploy_keys = DeployKeysPresenter
.new(@project, current_user: current_user)
define_protected_branches
end
private
def define_protected_branches
load_protected_branches
@protected_branch = @project.protected_branches.new
load_gon_index
end
def push_rule
@push_rule ||= PushRule.find_or_create_by(is_sample: true)
end
def remote_mirror
@remote_mirror = @project.remote_mirrors.first_or_initialize
end
def load_protected_branches
@protected_branches = @project.protected_branches.order(:name).page(params[:page])
end
def access_levels_options
{
push_access_levels: {
roles: ProtectedBranch::PushAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
},
merge_access_levels: {
roles: ProtectedBranch::MergeAccessLevel.human_access_levels.map { |id, text| { id: id, text: text, before_divider: true } },
},
selected_merge_access_levels: @protected_branch.merge_access_levels.map { |access_level| access_level.user_id || access_level.access_level },
selected_push_access_levels: @protected_branch.push_access_levels.map { |access_level| access_level.user_id || access_level.access_level }
}
end
def open_branches
branches = @project.open_branches.map { |br| { text: br.name, id: br.name, title: br.name } }
{ open_branches: branches }
end
def load_gon_index
params = open_branches
params[:current_project_id] = @project.id if @project
gon.push(params.merge(access_levels_options))
end
end
end
end
......@@ -20,6 +20,7 @@
#
class IssuableFinder
NONE = '0'.freeze
VALID_PARAMS = %i(scope state group_id project_id milestone_title assignee_id search label_name sort assignee_username author_id author_username authorized_only due_date iids non_archived weight).freeze
attr_accessor :current_user, :params
......@@ -80,7 +81,7 @@ class IssuableFinder
counts[:all] = counts.values.sum
counts[:opened] += counts[:reopened]
counts
counts.with_indifferent_access
end
def find_by!(*params)
......
......@@ -5,10 +5,22 @@ module BoardsHelper
{
endpoint: namespace_project_boards_path(@project.namespace, @project),
board_id: board.id,
board_milestone_title: board&.milestone&.title,
disabled: "#{!can?(current_user, :admin_list, @project)}",
issue_link_base: namespace_project_issues_path(@project.namespace, @project),
root_path: root_path,
bulk_update_path: bulk_update_namespace_project_issues_path(@project.namespace, @project),
}
end
def current_board_json
board = @board || @boards.first
board.to_json(
only: [:id, :name, :milestone_id],
include: {
milestone: { only: [:title] }
}
)
end
end
module EE
module GeoHelper
def node_status_icon(node)
if node.primary?
icon 'star fw', class: 'has-tooltip', title: 'Primary node'
else
status =
if node.enabled?
node.healthy? ? 'healthy' : 'unhealthy'
else
'disabled'
end
icon 'globe fw',
class: "geo-node-icon-#{status} has-tooltip",
title: status.capitalize
end
end
def toggle_node_button(node)
btn_class, title, data =
if node.enabled?
['warning', 'Disable node', { confirm: 'Disabling a node stops the sync process. Are you sure?' }]
else
['success', 'Enable node']
end
link_to icon('power-off fw', text: title),
toggle_admin_geo_node_path(node),
method: :post,
class: "btn btn-sm btn-#{btn_class} prepend-left-10 has-tooltip",
title: title,
data: data
end
end
end
......@@ -74,6 +74,10 @@ module GitlabRoutingHelper
namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
end
def environment_metrics_path(environment, *args)
metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args)
end
def issue_path(entity, *args)
namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args)
end
......
......@@ -134,10 +134,7 @@ module IssuablesHelper
state_title = titles[state] || state.to_s.humanize
count =
Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
issuables_count_for_state(issuable_type, state)
end
count = cached_issuables_count_for_state(issuable_type, state)
html = content_tag(:span, state_title)
html << " " << content_tag(:span, number_with_delimiter(count), class: 'badge')
......@@ -145,6 +142,12 @@ module IssuablesHelper
html.html_safe
end
def cached_issuables_count_for_state(issuable_type, state)
Rails.cache.fetch(issuables_state_counter_cache_key(issuable_type, state), expires_in: 2.minutes) do
issuables_count_for_state(issuable_type, state)
end
end
def cached_assigned_issuables_count(assignee, issuable_type, state)
cache_key = hexdigest(['assigned_issuables_count', assignee.id, issuable_type, state].join('-'))
Rails.cache.fetch(cache_key, expires_in: 2.minutes) do
......
module Emails
module CsvExport
def issues_csv_email(user, project, csv_data, export_status)
@project = project
@issues_count = export_status.fetch(:rows_expected)
@written_count = export_status.fetch(:rows_written)
@truncated = export_status.fetch(:truncated)
filename = "#{project.full_path.parameterize}_issues_#{Date.today.iso8601}.csv"
attachments[filename] = { content: csv_data, mime_type: 'text/csv' }
mail(to: user.notification_email, subject: subject("Exported issues")) do |format|
format.html { render layout: 'mailer' }
format.text { render layout: 'mailer' }
end
end
end
end
......@@ -3,6 +3,7 @@ class Notify < BaseMailer
include Emails::AdminNotification
include Emails::Issues
include Emails::CsvExport
include Emails::MergeRequests
include Emails::Notes
include Emails::Projects
......
class Board < ActiveRecord::Base
belongs_to :project
belongs_to :milestone
has_many :lists, -> { order(:list_type, :position) }, dependent: :delete_all
......@@ -8,4 +9,26 @@ class Board < ActiveRecord::Base
def done_list
lists.merge(List.done).take
end
def milestone
if milestone_id == Milestone::Upcoming.id
Milestone::Upcoming
else
super
end
end
def as_json(options = {})
milestone_attrs = options.fetch(:include, {})
.extract!(:milestone)
.dig(:milestone, :only)
super(options).tap do |json|
if milestone.present? && milestone_attrs.present?
json[:milestone] = milestone_attrs.each_with_object({}) do |attr, json|
json[attr] = milestone.public_send(attr)
end
end
end
end
end
......@@ -178,6 +178,14 @@ module Issuable
end
end
def labels_hash
issue_labels = Hash.new { |h, k| h[k] = [] }
eager_load(:labels).pluck(:id, 'labels.title').each do |issue_id, label|
issue_labels[issue_id] << label
end
issue_labels
end
# Includes table keys in group by clause when sorting
# preventing errors in postgres
#
......
module EE
# Repository EE mixin
#
# This module is intended to encapsulate EE-specific model logic
# and be prepended in the `Repository` model
module Repository
extend ActiveSupport::Concern
# Runs code after a repository has been synced.
def after_sync
expire_all_method_caches
expire_branch_cache
expire_content_cache
end
end
end
......@@ -149,6 +149,14 @@ class Environment < ActiveRecord::Base
project.deployment_service.rollout_status(self) if deployment_service_ready?
end
def has_metrics?
project.monitoring_service.present? && available? && last_deployment.present?
end
def metrics
project.monitoring_service.metrics(self) if has_metrics?
end
# An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has
# the following properties:
......
class Geo::BaseRegistry < ActiveRecord::Base
self.abstract_class = true
if Gitlab::Geo.secondary? || Rails.env.test?
establish_connection Rails.configuration.geo_database
end
end
class Geo::FileRegistry < Geo::BaseRegistry
end
class Geo::ProjectRegistry < Geo::BaseRegistry
belongs_to :project
validates :project, presence: true
scope :failed, -> { where.not(last_repository_synced_at: nil).where(last_repository_successful_sync_at: nil) }
scope :synced, -> { where.not(last_repository_synced_at: nil, last_repository_successful_sync_at: nil) }
end
class GeoNode < ActiveRecord::Base
include Presentable
belongs_to :geo_node_key, dependent: :destroy
belongs_to :oauth_application, class_name: 'Doorkeeper::Application', dependent: :destroy
belongs_to :system_hook, dependent: :destroy
......@@ -31,6 +33,10 @@ class GeoNode < ActiveRecord::Base
mode: :per_attribute_iv,
encode: true
def secondary?
!primary
end
def uri
if relative_url_root
relative_url = relative_url_root.starts_with?('/') ? relative_url_root : "/#{relative_url_root}"
......@@ -67,6 +73,10 @@ class GeoNode < ActiveRecord::Base
geo_api_url("transfers/#{file_type}/#{file_id}")
end
def status_url
geo_api_url('status')
end
def oauth_callback_url
Gitlab::Routing.url_helpers.oauth_geo_callback_url(url_helper_args)
end
......@@ -79,12 +89,6 @@ class GeoNode < ActiveRecord::Base
self.primary? ? false : !oauth_application.present?
end
def backfill_repositories
if Gitlab::Geo.enabled? && !primary?
GeoScheduleBackfillWorker.perform_async(id)
end
end
private
def geo_api_url(suffix)
......@@ -117,8 +121,10 @@ class GeoNode < ActiveRecord::Base
end
def build_dependents
self.build_geo_node_key if geo_node_key.nil?
update_system_hook!
unless persisted?
self.build_geo_node_key if geo_node_key.nil?
update_system_hook!
end
end
def update_dependents_attributes
......
class GeoNodeStatus
include ActiveModel::Model
attr_writer :health
def health
@health ||= HealthCheck::Utils.process_checks(['geo'])
end
def healthy?
health.blank?
end
def repositories_count
@repositories_count ||= Project.count
end
def repositories_count=(value)
@repositories_count = value.to_i
end
def repositories_synced_count
@repositories_synced_count ||= Geo::ProjectRegistry.synced.count
end
def repositories_synced_count=(value)
@repositories_synced_count = value.to_i
end
def repositories_synced_in_percentage
sync_percentage(repositories_count, repositories_synced_count)
end
def repositories_failed_count
@repositories_failed_count ||= Geo::ProjectRegistry.failed.count
end
def repositories_failed_count=(value)
@repositories_failed_count = value.to_i
end
def lfs_objects_total
@lfs_objects_total ||= LfsObject.count
end
def lfs_objects_total=(value)
@lfs_objects_total = value.to_i
end
def lfs_objects_synced
@lfs_objects_synced ||= Geo::FileRegistry.where(file_type: :lfs).count
end
def lfs_objects_synced=(value)
@lfs_objects_synced = value.to_i
end
def lfs_objects_synced_in_percentage
sync_percentage(lfs_objects_total, lfs_objects_synced)
end
private
def sync_percentage(total, synced)
return 0 if total.zero?
(synced.to_f / total.to_f) * 100.0
end
end
......@@ -18,6 +18,7 @@ class Milestone < ActiveRecord::Base
cache_markdown_field :description
belongs_to :project
has_many :boards
has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests
......
......@@ -114,6 +114,7 @@ class Project < ActiveRecord::Base
has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project
has_one :external_wiki_service, dependent: :destroy
has_one :kubernetes_service, dependent: :destroy, inverse_of: :project
has_one :prometheus_service, dependent: :destroy, inverse_of: :project
has_one :index_status, dependent: :destroy
has_one :mock_ci_service, dependent: :destroy
......@@ -875,6 +876,14 @@ class Project < ActiveRecord::Base
@deployment_service ||= deployment_services.reorder(nil).find_by(active: true)
end
def monitoring_services
services.where(category: :monitoring)
end
def monitoring_service
@monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true)
end
def jira_tracker?
issues_tracker.to_param == 'jira'
end
......
# Base class for monitoring services
#
# These services integrate with a deployment solution like Prometheus
# to provide additional features for environments.
class MonitoringService < Service
default_value_for :category, 'monitoring'
def self.supported_events
%w()
end
# Environments have a number of metrics
def metrics(environment)
raise NotImplementedError
end
end
class PrometheusService < MonitoringService
include ReactiveCaching
self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] }
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
# Access to prometheus is directly through the API
prop_accessor :api_url
with_options presence: true, if: :activated? do
validates :api_url, url: true
end
after_save :clear_reactive_cache!
def initialize_properties
if properties.nil?
self.properties = {}
end
end
def title
'Prometheus'
end
def description
'Prometheus monitoring'
end
def help
'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.'
end
def self.to_param
'prometheus'
end
def fields
[
{
type: 'text',
name: 'api_url',
title: 'API URL',
placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/'
}
]
end
# Check we can connect to the Prometheus API
def test(*args)
client.ping
{ success: true, result: 'Checked API endpoint' }
rescue Gitlab::PrometheusError => err
{ success: false, result: err }
end
def metrics(environment)
with_reactive_cache(environment.slug) do |data|
data
end
end
# Cache metrics for specific environment
def calculate_reactive_cache(environment_slug)
return unless active? && project && !project.pending_delete?
memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024}
cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))}
{
success: true,
metrics: {
# Memory used in MB
memory_values: client.query_range(memory_query, start: 8.hours.ago),
memory_current: client.query(memory_query),
# CPU Usage rate in cores.
cpu_values: client.query_range(cpu_query, start: 8.hours.ago),
cpu_current: client.query(cpu_query)
},
last_update: Time.now.utc
}
rescue Gitlab::PrometheusError => err
{ success: false, result: err.message }
end
def client
@prometheus ||= Gitlab::Prometheus.new(api_url: api_url)
end
end
......@@ -6,6 +6,7 @@ class Repository
include Gitlab::ShellAdapter
include Elastic::RepositoriesSearch
include RepositoryMirroring
prepend EE::Repository
attr_accessor :path_with_namespace, :project
......
......@@ -234,6 +234,7 @@ class Service < ActiveRecord::Base
mattermost
pipelines_email
pivotaltracker
prometheus
pushover
redmine
slack_slash_commands
......
class GeoNodePresenter < Gitlab::View::Presenter::Delegated
presents :geo_node
delegate :healthy?, :health, :repositories_count, :repositories_synced_count,
:repositories_synced_in_percentage, :repositories_failed_count,
:lfs_objects_total, :lfs_objects_synced, :lfs_objects_synced_in_percentage,
to: :status
private
def status
@status ||= Geo::NodeStatusService.new.call(geo_node.status_url)
end
end
module Projects
module Settings
class DeployKeysPresenter < Gitlab::View::Presenter::Simple
presents :project
delegate :size, to: :enabled_keys, prefix: true
delegate :size, to: :available_project_keys, prefix: true
delegate :size, to: :available_public_keys, prefix: true
def new_key
@key ||= DeployKey.new
end
def enabled_keys
@enabled_keys ||= project.deploy_keys
end
def any_keys_enabled?
enabled_keys.any?
end
def available_keys
@available_keys ||= current_user.accessible_deploy_keys - enabled_keys
end
def available_project_keys
@available_project_keys ||= current_user.project_deploy_keys - enabled_keys
end
def any_available_project_keys_enabled?
available_project_keys.any?
end
def key_available?(deploy_key)
available_keys.include?(deploy_key)
end
def available_public_keys
return @available_public_keys if defined?(@available_public_keys)
@available_public_keys ||= DeployKey.are_public - enabled_keys
# Public keys that are already used by another accessible project are already
# in @available_project_keys.
@available_public_keys -= available_project_keys
end
def any_available_public_keys_enabled?
available_public_keys.any?
end
def to_partial_path
'projects/deploy_keys/index'
end
def form_partial_path
'projects/deploy_keys/form'
end
end
end
end
module Boards
class UpdateService < BaseService
def execute(board)
board.update(name: params[:name])
board.update(name: params[:name], milestone_id: params[:milestone_id])
end
end
end
module Geo
class FileDownloadService
attr_reader :object_type, :object_db_id
LEASE_TIMEOUT = 8.hours.freeze
def initialize(object_type, object_db_id)
@object_type = object_type
@object_db_id = object_db_id
end
def execute
try_obtain_lease do |lease|
case object_type
when :lfs
download_lfs_object
else
log("unknown file type: #{object_type}")
end
end
end
private
def try_obtain_lease
uuid = Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT).try_obtain
return unless uuid.present?
begin
yield
ensure
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
end
end
def download_lfs_object
lfs_object = LfsObject.find_by_id(object_db_id)
return unless lfs_object.present?
transfer = ::Gitlab::Geo::LfsTransfer.new(lfs_object)
bytes_downloaded = transfer.download_from_primary
success = bytes_downloaded && bytes_downloaded >= 0
update_registry(bytes_downloaded) if success
success
end
def log(message)
Rails.logger.info "#{self.class.name}: #{message}"
end
def update_registry(bytes_downloaded)
transfer = Geo::FileRegistry.find_or_initialize_by(
file_type: object_type,
file_id: object_db_id)
transfer.bytes = bytes_downloaded
transfer.save
end
def lease_key
"file_download_service:#{object_type}:#{object_db_id}"
end
end
end
module Geo
class NodeStatusService
include Gitlab::CurrentSettings
include HTTParty
KEYS = %w(health repositories_count repositories_synced_count repositories_failed_count lfs_objects_total lfs_objects_synced).freeze
# HTTParty timeout
default_timeout current_application_settings.geo_status_timeout
def call(status_url)
values =
begin
response = self.class.get(status_url, headers: headers)
if response.success?
response.parsed_response.values_at(*KEYS)
else
["Could not connect to Geo node - HTTP Status Code: #{response.code}"]
end
rescue HTTParty::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED => e
[e.message]
end
GeoNodeStatus.new(KEYS.zip(values).to_h)
end
private
def headers
Gitlab::Geo::BaseRequest.new.headers
end
end
end
......@@ -17,8 +17,11 @@ module Geo
content = { projects: projects }.to_json
::Gitlab::Geo.secondary_nodes.each do |node|
next unless node.enabled?
notify_url = node.send(notify_url_method.to_sym)
success, message = notify(notify_url, content)
unless success
Rails.logger.error("GitLab failed to notify #{node.url} to #{notify_url} : #{message}")
queue.store_batched_data(projects)
......
module Geo
class RepositoryBackfillService
attr_reader :project, :geo_node
attr_reader :project_id
def initialize(project, geo_node)
@project = project
@geo_node = geo_node
LEASE_TIMEOUT = 8.hours.freeze
LEASE_KEY_PREFIX = 'repository_backfill_service'.freeze
def initialize(project_id)
@project_id = project_id
end
def execute
geo_node.system_hook.execute(hook_data, 'system_hooks')
try_obtain_lease do
log('Started repository sync')
started_at, finished_at = fetch_repositories
update_registry(started_at, finished_at)
log('Finished repository sync')
end
rescue ActiveRecord::RecordNotFound
logger.error("Couldn't find project with ID=#{project_id}, skipping syncing")
end
private
def hook_data
{
event_name: 'repository_update',
project_id: project.id,
project: project.hook_attrs,
remote_url: project.ssh_url_to_repo
}
def project
@project ||= Project.find(project_id)
end
def fetch_repositories
started_at = DateTime.now
finished_at = nil
begin
fetch_project_repository
fetch_wiki_repository
expire_repository_caches
finished_at = DateTime.now
rescue Gitlab::Shell::Error => e
Rails.logger.error "Error syncing repository for project #{project.path_with_namespace}: #{e}"
end
[started_at, finished_at]
end
def fetch_project_repository
log('Fetching project repository')
project.create_repository unless project.repository_exists?
project.repository.fetch_geo_mirror(ssh_url_to_repo)
end
def fetch_wiki_repository
# Second .wiki call returns a Gollum::Wiki, and it will always create the physical repository when not found
if project.wiki.wiki.exist?
log('Fetching wiki repository')
project.wiki.repository.fetch_geo_mirror(ssh_url_to_wiki)
end
end
def expire_repository_caches
log('Expiring caches')
project.repository.after_sync
end
def try_obtain_lease
log('Trying to obtain lease to sync repository')
repository_lease = Gitlab::ExclusiveLease.new(lease_key, timeout: LEASE_TIMEOUT).try_obtain
if repository_lease.nil?
log('Could not obtain lease to sync repository')
return
end
yield
# We should release the lease for a repository, only if we have obtained
# it. If something went wrong when syncing the repository, we should wait
# for the lease timeout to try again.
log('Releasing leases to sync repository')
Gitlab::ExclusiveLease.cancel(lease_key, repository_lease)
end
def update_registry(started_at, finished_at)
log('Updating registry information')
registry = Geo::ProjectRegistry.find_or_initialize_by(project_id: project.id)
registry.last_repository_synced_at = started_at
registry.last_repository_successful_sync_at = finished_at if finished_at
registry.save
end
def lease_key
@lease_key ||= "#{LEASE_KEY_PREFIX}:#{project.id}"
end
def primary_ssh_path_prefix
Gitlab::Geo.primary_ssh_path_prefix
end
def ssh_url_to_repo
"#{primary_ssh_path_prefix}#{project.path_with_namespace}.git"
end
def ssh_url_to_wiki
"#{primary_ssh_path_prefix}#{project.path_with_namespace}.wiki.git"
end
def log(message)
Rails.logger.info "#{self.class.name}: #{message} for project #{project.path_with_namespace} (#{project.id})"
end
end
end
module Geo
class ScheduleBackfillService
attr_accessor :geo_node_id
def initialize(geo_node_id)
@geo_node_id = geo_node_id
end
def execute
return if geo_node_id.nil?
Project.find_each(batch_size: 100) do |project|
GeoRepositoryBackfillWorker.perform_async(geo_node_id, project.id) if project.valid_repo?
end
end
end
end
module Issues
class ExportCsvService
include Gitlab::Routing.url_helpers
include GitlabRoutingHelper
# Target attachment size before base64 encoding
TARGET_FILESIZE = 15000000
def initialize(issues_relation)
@issues = issues_relation
@labels = @issues.labels_hash
end
def csv_data
csv_builder.render(TARGET_FILESIZE)
end
def email(user, project)
Notify.issues_csv_email(user, project, csv_data, csv_builder.status).deliver_now
end
def csv_builder
@csv_builder ||=
CsvBuilder.new(@issues.includes(:author, :assignee), header_to_value_hash)
end
private
def header_to_value_hash
{
'Issue ID' => 'iid',
'URL' => -> (issue) { issue_url(issue) },
'Title' => 'title',
'State' => -> (issue) { issue.closed? ? 'Closed' : 'Open' },
'Description' => 'description',
'Author' => 'author_name',
'Author Username' => -> (issue) { issue.author&.username },
'Assignee' => 'assignee_name',
'Assignee Username' => -> (issue) { issue.assignee&.username },
'Confidential' => -> (issue) { issue.confidential? ? 'Yes' : 'No' },
'Due Date' => -> (issue) { issue.due_date&.to_s(:csv) },
'Created At (UTC)' => -> (issue) { issue.created_at&.to_s(:csv) },
'Updated At (UTC)' => -> (issue) { issue.updated_at&.to_s(:csv) },
'Milestone' => -> (issue) { issue.milestone&.title },
'Labels' => -> (issue) { @labels[issue.id].sort.join(',').presence }
}
end
end
end
......@@ -13,6 +13,10 @@ module MergeRequests
source,
merge_request.target_branch,
merge_request: merge_request)
rescue GitHooksService::PreReceiveError => e
raise MergeError, e.message
rescue StandardError => e
raise MergeError, "Something went wrong during merge: #{e.message}"
ensure
merge_request.update(in_progress_merge_commit_sha: nil)
end
......
......@@ -52,6 +52,7 @@ module Projects
flush_caches(project, wiki_path)
trash_repositories!
remove_tracking_entries!
log_info("Project \"#{project.name}\" was removed")
end
......@@ -98,6 +99,12 @@ module Projects
project.container_registry_repository.delete_tags
end
def remove_tracking_entries!
return unless Gitlab::Geo.secondary?
Geo::ProjectRegistry.where(project_id: project.id).delete_all
end
def raise_error(message)
raise DestroyError.new(message)
end
......
......@@ -642,5 +642,18 @@
Maximum time for web terminal websocket connection (in seconds).
0 for unlimited.
- if Gitlab::Geo.license_allows?
%fieldset
%legend GitLab Geo
%p
These settings will only take effect if Geo is enabled and require a restart to take effect.
.form-group
= f.label :geo_status_timeout, 'Connection timeout', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :geo_status_timeout, class: 'form-control'
.help-block
The amount of seconds after which a request to get a secondary node
status will time out.
.form-actions
= f.submit 'Save', class: 'btn btn-save'
......@@ -21,7 +21,6 @@
%p.help-block
Paste a machine public key here for the GitLab user this node runs on. Read more about how to generate it
= link_to "here", help_page_path("ssh/README")
.form-actions
= f.submit 'Add Node', class: 'btn btn-create'
%hr
......@@ -8,7 +8,7 @@
%hr
= render :partial => 'form', locals: {geo_node: @node} if Gitlab::Geo.license_allows?
= render partial: 'form', locals: {geo_node: @node} if Gitlab::Geo.license_allows?
- if @nodes.any?
.panel.panel-default
......@@ -19,10 +19,22 @@
%li
.list-item-name
%span
= node.primary ? icon('star fw') : icon('globe fw')
= node_status_icon(node)
%strong= node.url
%p
%span.help-block= node.primary ? 'Primary node' : 'Secondary node'
- if node.primary?
%span.help-block Primary node
- else
%p
%span.help-block
Repositories synced: #{node.repositories_synced_count}/#{node.repositories_count} (#{number_to_percentage(node.repositories_synced_in_percentage, precision: 2)})
%p
%span.help-block
Repositories failed: #{node.repositories_failed_count}
%p
%span.help-block
LFS objects synced: #{node.lfs_objects_synced}/#{node.lfs_objects_total} (#{number_to_percentage(node.lfs_objects_synced_in_percentage, precision: 2)})
%p
%span.help-block= node.healthy? ? 'No Health Problems Detected' : node.health
.pull-right
- if Gitlab::Geo.license_allows?
......@@ -30,10 +42,8 @@
= link_to repair_admin_geo_node_path(node), method: :post, title: 'OAuth application is missing', class: 'btn btn-default btn-sm prepend-left-10' do
= icon('exclamation-triangle fw')
Repair authentication
- unless node.primary?
= link_to backfill_repositories_admin_geo_node_path(node), method: :post, class: 'btn btn-primary btn-sm prepend-left-10' do
= icon 'map-signs'
Backfill all repositories
- if node.secondary?
= toggle_node_button(node)
= link_to admin_geo_node_path(node), data: { confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-remove btn-sm prepend-left-10' do
= icon 'trash'
Remove
......@@ -34,6 +34,9 @@
%code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database)
%li
%code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations)
- if Gitlab::Geo.secondary?
%li
%code= health_check_url(token: current_application_settings.health_check_access_token, checks: :geo)
%hr
.panel.panel-default
......
......@@ -4,32 +4,20 @@
%span
Members
- if can_edit
= nav_link(controller: :deploy_keys) do
= link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do
= nav_link(controller: :repository) do
= link_to namespace_project_settings_repository_path(@project.namespace, @project), title: 'Repository' do
%span
Deploy Keys
Repository
= nav_link(controller: :integrations) do
= link_to namespace_project_settings_integrations_path(@project.namespace, @project), title: 'Integrations' do
%span
Integrations
= nav_link(controller: :protected_branches) do
= link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do
%span
Protected Branches
- if @project.feature_available?(:builds, current_user)
= nav_link(controller: :ci_cd) do
= link_to namespace_project_settings_ci_cd_path(@project.namespace, @project), title: 'CI/CD Pipelines' do
%span
CI/CD Pipelines
= nav_link(controller: :push_rules) do
= link_to namespace_project_push_rules_path(@project.namespace, @project), title: "Push Rules" do
%span
Push Rules
= nav_link(controller: :mirrors) do
= link_to namespace_project_mirror_path(@project.namespace, @project), title: 'Mirror Repository', data: {placement: 'right'} do
%span
Mirror Repository
= nav_link(controller: :pages) do
= link_to namespace_project_pages_path(@project.namespace, @project), title: 'Pages', data: {placement: 'right'} do
%span
......
%p{ style: 'font-size:18px; text-align:center; line-height:30px;' }
Your CSV export of #{ pluralize(@written_count, 'issue') } from project
%a{ href: project_url(@project), style: "color:#3777b0; text-decoration:none; display:block;" }
= @project.full_name
has been added to this email as an attachment.
- if @truncated
%p
This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. #{ @written_count } of #{ @issues_count } issues have been included. Consider re-exporting with a narrower selection of issues.
Your CSV export of <%= pluralize(@written_count, 'issue') %> from project <%= @project.full_name %> (<%= project_url(@project) %>) has been added to this email as an attachment.
<% if @truncated %>
This attachment has been truncated to avoid exceeding a maximum allowed attachment size of 20MB. <%= @written_count %> of <%= @issues_count %> issues have been included. Consider re-exporting with a narrower selection of issues.
<% end %>
\ No newline at end of file
%boards-selector{ "inline-template" => true,
":current-board" => board.to_json }
":current-board" => current_board_json,
"milestone-path" => namespace_project_milestones_path(board.project.namespace, board.project, :json) }
.dropdown
%button.dropdown-menu-toggle{ "@click" => "loadBoards",
data: { toggle: "dropdown" } }
......@@ -25,7 +26,8 @@
= icon("spin spinner")
- if can?(current_user, :admin_board, @project)
%board-selector-form{ "inline-template" => true,
"v-if" => "currentPage === 'new' || currentPage === 'edit'" }
":milestone-path" => "milestonePath",
"v-if" => "currentPage === 'new' || currentPage === 'edit' || currentPage === 'milestone'" }
= render "projects/boards/components/form"
.dropdown-content.board-selector-page-two{ "v-if" => "currentPage === 'delete'" }
%p
......@@ -47,6 +49,9 @@
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('edit')" }
Edit board name
%li
%a{ "href" => "#", "@click.stop.prevent" => "showPage('milestone')" }
Edit board milestone
%li{ "v-if" => "showDelete" }
%a.text-danger{ "href" => "#", "@click.stop.prevent" => "showPage('delete')" }
Delete board
.dropdown-content.board-selector-page-two
%form{ "@submit.prevent" => "submit" }
%label.label-light{ for: "board-new-name" }
Board name
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
%input{ type: "hidden",
id: "board-milestone",
"v-model.number" => "board.milestone_id" }
%div{ "v-if" => "currentPage !== 'milestone'" }
%label.label-light{ for: "board-new-name" }
Board name
%input.form-control{ type: "text",
id: "board-new-name",
"v-model" => "board.name" }
.dropdown.board-inner-milestone-dropdown{ ":class" => "{ open: milestoneDropdownOpen }",
"v-if" => "currentPage === 'new'" }
%label.label-light{ for: "board-milestone" }
Board milestone
%button.dropdown-menu-toggle.wide{ type: "button",
"@click.stop.prevent" => "loadMilestones" }
{{ milestoneToggleText }}
= icon("chevron-down")
.dropdown-menu.dropdown-menu-selectable{ "v-if" => "milestoneDropdownOpen" }
.dropdown-content
%ul
%li{ "v-for" => "milestone in extraMilestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"@click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
%li.divider
%li{ "v-for" => "milestone in milestones" }
%a{ href: "#",
":class" => "{ 'is-active': milestone.id === board.milestone_id }",
"@click.stop.prevent" => "selectMilestone(milestone)" }
{{ milestone.title }}
= dropdown_loading
%span
Only show issues scheduled for the selected milestone
%board-milestone-select{ "v-if" => "currentPage == 'milestone'",
":milestone-path" => "milestonePath",
":select-milestone" => "selectMilestone",
":board" => "board" }
.clearfix.prepend-top-10
%button.btn.btn-primary.pull-left{ type: "submit",
":disabled" => "board.name === ''",
":disabled" => "submitDisabled",
"ref" => "'submit-btn'" }
{{ buttonText }}
%button.btn.btn-default.pull-right{ type: "button",
......
......@@ -18,7 +18,7 @@
%span.key-created-at
created #{time_ago_with_tooltip(deploy_key.created_at)}
.visible-xs-block.visible-sm-block
- if @available_keys.include?(deploy_key)
- if @deploy_keys.key_available?(deploy_key)
= link_to enable_namespace_project_deploy_key_path(@project.namespace, @project, deploy_key), class: "btn btn-sm prepend-left-10", method: :put do
Enable
- else
......
= form_for [@project.namespace.becomes(Namespace), @project, @key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input" } do |f|
= form_errors(@key)
= form_for [@project.namespace.becomes(Namespace), @project, @deploy_keys.new_key], url: namespace_project_deploy_keys_path, html: { class: "js-requires-input" } do |f|
= form_errors(@deploy_keys.new_key)
.form-group
= f.label :title, class: "label-light"
= f.text_field :title, class: 'form-control', autofocus: true, required: true
......
- page_title "Deploy Keys"
.row.prepend-top-default
.col-lg-3.profile-settings-sidebar
%h4.prepend-top-0
= page_title
Deploy Keys
%p
Deploy keys allow read-only access to your repository. Deploy keys can be used for CI, staging or production servers. You can create a deploy key or add an existing one.
.col-lg-9
%h5.prepend-top-0
Create a new deploy key for this project
= render "form"
= render @deploy_keys.form_partial_path
.col-lg-9.col-lg-offset-3
%hr
.col-lg-9.col-lg-offset-3.append-bottom-default.deploy-keys
%h5.prepend-top-0
Enabled deploy keys for this project (#{@enabled_keys.size})
- if @enabled_keys.any?
Enabled deploy keys for this project (#{@deploy_keys.enabled_keys_size})
- if @deploy_keys.any_keys_enabled?
%ul.well-list
= render @enabled_keys
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.enabled_keys, as: :deploy_key
- else
.settings-message.text-center
No deploy keys found. Create one with the form above or add existing one below.
No deploy keys found. Create one with the form above.
%h5.prepend-top-default
Deploy keys from projects you have access to (#{@available_project_keys.size})
- if @available_project_keys.any?
Deploy keys from projects you have access to (#{@deploy_keys.available_project_keys_size})
- if @deploy_keys.any_available_project_keys_enabled?
%ul.well-list
= render @available_project_keys
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_project_keys, as: :deploy_key
- else
.settings-message.text-center
No deploy keys from your projects could be found. Create one with the form above or add existing one below.
- if @available_public_keys.any?
No deploy keys from your projects could be found. Create one with the form above
- if @deploy_keys.any_available_public_keys_enabled?
%h5.prepend-top-default
Public deploy keys available to any project (#{@available_public_keys.size})
Public deploy keys available to any project (#{@deploy_keys.available_public_keys_size})
%ul.well-list
= render @available_public_keys
= render partial: 'projects/deploy_keys/deploy_key', collection: @deploy_keys.available_public_keys, as: :deploy_key
- environment = local_assigns.fetch(:environment)
- return unless environment.has_metrics? && can?(current_user, :read_environment, environment)
= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
= icon('area-chart')
- @no_container = true
- page_title "Metrics for environment", @environment.name
= render "projects/pipelines/head"
%div{ class: container_class }
.top-area
.row
.col-sm-6
%h3.page-title
Environment:
= @environment.name
.col-sm-6
.nav-controls
= render 'projects/deployments/actions', deployment: @environment.last_deployment
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
.row
.col-sm-12
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
......@@ -8,6 +8,7 @@
%h3.page-title= @environment.name
.col-md-3
.nav-controls
= render 'projects/environments/metrics_button', environment: @environment
= render 'projects/environments/terminal_button', environment: @environment
= render 'projects/environments/external_url', environment: @environment
- if can?(current_user, :update_environment, @environment)
......
.issues-export-modal.modal
.modal-dialog
.modal-content
.modal-header
%a.close{ href: '#', 'data-dismiss' => 'modal' } ×
.export-svg-container.pull-right
= render 'projects/issues/export_issues/export_issues_list.svg'
%h3
Export issues
.modal-header
= icon('check', { class: 'export-checkmark' })
%strong
#{pluralize(cached_issuables_count_for_state(:issues, params[:state]), 'issue')} selected
.modal-body
%div
The CSV export will be created in the background. Once finished, it will be sent to
%strong= @current_user.notification_email
in an attachment.
.modal-footer
= link_to 'Export issues', export_csv_namespace_project_issues_path(@project.namespace, @project, params.permit(IssuableFinder::VALID_PARAMS)), method: :post, class: 'btn btn-success pull-left', title: 'Export issues'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 238 111" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="4" width="82" rx="3" height="28" fill="#fff"/><path id="5" d="m68.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874" fill="#fc8a51"/><path id="6" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><circle id="2" cx="16" cy="14" r="7"/><circle id="0" cx="16" cy="14" r="7"/><mask id="3" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="1" width="14" height="14" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask></defs><g fill="none" fill-rule="evenodd"><rect width="98" height="111" fill="#fff" rx="6"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 6.01v98.99c0 1.11.897 2.01 2 2.01h85.998c1.105 0 2-.897 2-2.01v-98.99c0-1.11-.897-2.01-2-2.01h-85.998c-1.105 0-2 .897-2 2.01m-4 0c0-3.318 2.685-6.01 6-6.01h85.998c3.314 0 6 2.689 6 6.01v98.99c0 3.318-2.685 6.01-6 6.01h-85.998c-3.314 0-6-2.689-6-6.01v-98.99"/><rect width="76" height="85" x="11" y="12" fill="#f9f9f9" rx="3"/><g transform="translate(37 59)"><use xlink:href="#4"/><path fill="#e5e5e5" fill-rule="nonzero" d="m4 24h74v-20h-74v20m-4-21c0-1.655 1.338-2.996 2.991-2.996h76.02c1.652 0 2.991 1.35 2.991 2.996v22.01c0 1.655-1.338 2.996-2.991 2.996h-76.02c-1.652 0-2.991-1.35-2.991-2.996v-22.01"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#1)" xlink:href="#0"/><use xlink:href="#5"/></g><g transform="translate(140)"><path fill="#fff" d="m0 4h94v103h-94z"/><path fill="#e5e5e5" fill-rule="nonzero" d="m0 74v30.993c0 3.318 2.687 6.01 6 6.01h85.998c3.316 0 6-2.69 6-6.01v-98.99c0-3.318-2.687-6.01-6-6.01h-85.998c-3.316 0-6 2.69-6 6.01v.993h4v-.993c0-1.11.896-2.01 2-2.01h85.998c1.105 0 2 .897 2 2.01v98.99c0 1.11-.896 2.01-2 2.01h-85.998c-1.105 0-2-.897-2-2.01v-30.993h-4"/><g fill="#f9f9f9"><rect width="82" height="28" x="8" y="12" rx="3"/><rect width="82" height="28" x="8" y="43" rx="3"/></g></g><g fill-rule="nonzero" transform="translate(148 73)"><use fill="#e5e5e5" xlink:href="#6"/><path fill="#6b4fbb" d="m17 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7"/></g><g transform="translate(25 24)"><use xlink:href="#4"/><use fill="#e5e5e5" fill-rule="nonzero" xlink:href="#6"/><use fill="#fff" stroke="#6b4fbb" stroke-width="8" mask="url(#3)" xlink:href="#2"/><use xlink:href="#5"/></g><g transform="translate(107 10)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><path fill="#6b4fbb" fill-rule="nonzero" d="m16 17c1.657 0 3-1.343 3-3 0-1.657-1.343-3-3-3-1.657 0-3 1.343-3 3 0 1.657 1.343 3 3 3m0 4c-3.866 0-7-3.134-7-7 0-3.866 3.134-7 7-7 3.866 0 7 3.134 7 7 0 3.866-3.134 7-7 7" id="7"/><use xlink:href="#5"/></g><g transform="translate(128 41)"><use xlink:href="#4"/><use fill="#fc8a51" fill-opacity=".3" fill-rule="nonzero" xlink:href="#6"/><use xlink:href="#7"/><path fill="#fc8a51" d="m66.926 12.09v-2.41c0-.665-.437-.888-.975-.507l-6.552 4.631c-.542.383-.539.998 0 1.379l6.552 4.631c.542.383.975.154.975-.507v-2.41h4.874c.668 0 1.2-.538 1.2-1.201v-2.406c0-.668-.537-1.201-1.2-1.201h-4.874"/></g></g></svg>
\ No newline at end of file
......@@ -8,17 +8,23 @@
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('filtered_search')
= page_specific_javascript_bundle_tag('issues')
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
- if project_issues(@project).exists?
- if current_user
= render "projects/issues/export_issues/csv_download"
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls
= link_to params.merge(rss_url_options), class: 'btn append-right-10 has-tooltip', title: 'Subscribe' do
= icon('rss')
- if current_user
%button.csv_download_link.btn.append-right-10.has-tooltip{ title: 'Export as CSV' }
= icon('download')
- if can? current_user, :create_issue, @project
= link_to new_namespace_project_issue_path(@project.namespace,
@project,
......
......@@ -7,4 +7,4 @@
%li
The update action will time out after 10 minutes. For big repositories, use a clone/push combination.
%li
The Git LFS/Annex objects will <strong>not</strong> be synced.
The Git LFS objects will <strong>not</strong> be synced.
- page_title "Mirror Repository"
.row
= form_errors(@project)
.row.prepend-top-default.append-bottom-default
......@@ -34,7 +32,7 @@
.form-group
= f.label :import_url, "Git repository URL", class: "label-light"
= f.text_field :import_url, class: 'form-control', placeholder: 'https://username:password@gitlab.company.com/group/project.git'
= render "instructions"
= render "projects/mirrors/instructions"
.form-group
= f.label :mirror_user_id, "Mirror user", class: "label-light"
= users_select_tag("project[mirror_user_id]", class: 'input-large', selected: @project.mirror_user_id || current_user.id,
......@@ -79,10 +77,9 @@
.form-group.has-feedback
= rm_form.label :url, "Git repository URL", class: "label-light"
= rm_form.text_field :url, class: "form-control", placeholder: 'https://username:password@gitlab.company.com/group/project.git'
= render "instructions"
= render "projects/mirrors/instructions"
.form-group
= rm_form.label :sync_time, "Synchronization time", class: "label-light append-bottom-0"
= rm_form.select :sync_time, options_for_select(mirror_sync_time_options, @remote_mirror.sync_time), {}, class: 'form-control remote-mirror-sync-time'
.col-sm-12.text-center
%hr
= f.submit 'Save changes', class: 'btn btn-create', name: 'update_remote_mirror'
= f.submit 'Save changes', class: 'btn btn-create', name: 'update_remote_mirror'
%hr
......@@ -25,6 +25,6 @@
- if can_admin_project
%th
%tbody
= render partial: @protected_branches, locals: { can_admin_project: can_admin_project }
= render partial: 'projects/protected_branches/protected_branch', collection: @protected_branches, locals: { can_admin_project: can_admin_project}
= paginate @protected_branches, theme: 'gitlab'
......@@ -10,7 +10,7 @@
= f.label :name, class: 'col-md-2 text-right' do
Branch:
.col-md-10
= render partial: "dropdown", locals: { f: f }
= render partial: "projects/protected_branches/dropdown", locals: { f: f }
.help-block
= link_to 'Wildcards', help_page_path('user/project/protected_branches', anchor: 'wildcard-protected-branches')
such as
......
- page_title "Protected branches"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('protected_branches')
.row.prepend-top-default.append-bottom-default
.col-lg-3
%h4.prepend-top-0
= page_title
Protected Branches
%p Keep stable branches secure and force developers to use merge requests.
%p.prepend-top-20
By default, protected branches are designed to:
......@@ -17,6 +16,6 @@
%p.append-bottom-0 Read more about #{link_to "protected branches", help_page_path("user/project/protected_branches"), class: "underlined-link"} and #{link_to "project permissions", help_page_path("user/permissions"), class: "underlined-link"}.
.col-lg-9
- if can? current_user, :admin_project, @project
= render 'create_protected_branch'
= render 'projects/protected_branches/create_protected_branch'
= render "branches_list"
= render "projects/protected_branches/branches_list"
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment