Merge remote-tracking branch 'upstream/master' into 8998_skip_pending_commits_if_not_head
* upstream/master: (197 commits) Add text to break up diagrams Implement review comments from @DouweM for !10467. Fix rubocop offence Linking to edit file directly Optimise trace handling code to use streaming instead of full read Use config.toml to configure Gitaly Fix indexes in container repositories table Recent search history for issues Fix rubocop Use change direction in spec Use be_pending Improve trigger_schedule.rb Implement a offset calculation on cron_parser_spec Clean up trigger_schedule_worker_spec.rb Improve instantiate recursion in cron_parser.rb Fix unnecessary changes in schema.rb Add empty line in cron_parser.rb Use parenthesis for respond_to :ref Define next_time as let in trigger_schedule_spec Remove next_run_at: nil from trigger_schedule_spec ...
1
.gitignore
vendored
|
@ -30,6 +30,7 @@ eslint-report.html
|
|||
/config/unicorn.rb
|
||||
/config/secrets.yml
|
||||
/config/sidekiq.yml
|
||||
/config/registry.key
|
||||
/coverage/*
|
||||
/coverage-javascript/
|
||||
/db/*.sqlite3
|
||||
|
|
|
@ -1 +1 @@
|
|||
0.5.0
|
||||
0.6.0
|
||||
|
|
3
Gemfile
|
@ -144,6 +144,9 @@ gem 'sidekiq-cron', '~> 0.4.4'
|
|||
gem 'redis-namespace', '~> 1.5.2'
|
||||
gem 'sidekiq-limit_fetch', '~> 3.4'
|
||||
|
||||
# Cron Parser
|
||||
gem 'rufus-scheduler', '~> 3.1.10'
|
||||
|
||||
# HTTP requests
|
||||
gem 'httparty', '~> 0.13.3'
|
||||
|
||||
|
|
|
@ -987,6 +987,7 @@ DEPENDENCIES
|
|||
rubocop-rspec (~> 1.12.0)
|
||||
ruby-fogbugz (~> 0.2.1)
|
||||
ruby-prof (~> 0.16.2)
|
||||
rufus-scheduler (~> 3.1.10)
|
||||
rugged (~> 0.25.1.1)
|
||||
sanitize (~> 2.0)
|
||||
sass-rails (~> 5.0.6)
|
||||
|
|
47
PROCESS.md
|
@ -33,7 +33,7 @@ core team members will mention this person.
|
|||
### Merge request coaching
|
||||
|
||||
Several people from the [GitLab team][team] are helping community members to get
|
||||
their contributions accepted by meeting our [Definition of done](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done).
|
||||
their contributions accepted by meeting our [Definition of done][done].
|
||||
|
||||
What you can expect from them is described at https://about.gitlab.com/jobs/merge-request-coach/.
|
||||
|
||||
|
@ -64,6 +64,49 @@ Merge requests may still be merged into master during this period,
|
|||
but they will go into the _next_ release, unless they are manually cherry-picked into the stable branch.
|
||||
By freezing the stable branches 2 weeks prior to a release, we reduce the risk of a last minute merge request potentially breaking things.
|
||||
|
||||
### Between the 1st and the 7th
|
||||
|
||||
These types of merge requests need special consideration:
|
||||
|
||||
* **Large features**: a large feature is one that is highlighted in the kick-off
|
||||
and the release blogpost; typically this will have its own channel in Slack
|
||||
and a dedicated team with front-end, back-end, and UX.
|
||||
* **Small features**: any other feature request.
|
||||
|
||||
**Large features** must be with a maintainer **by the 1st**. It's OK if they
|
||||
aren't completely done, but this allows the maintainer enough time to make the
|
||||
decision about whether this can make it in before the freeze. If the maintainer
|
||||
doesn't think it will make it, they should inform the developers working on it
|
||||
and the Product Manager responsible for the feature.
|
||||
|
||||
**Small features** must be with a reviewer (not necessarily maintainer) **by the
|
||||
3rd**.
|
||||
|
||||
Most merge requests from the community do not have a specific release
|
||||
target. However, if one does and falls into either of the above categories, it's
|
||||
the reviewer's responsibility to manage the above communication and assignment
|
||||
on behalf of the community member.
|
||||
|
||||
### On the 7th
|
||||
|
||||
Merge requests should still be complete, following the
|
||||
[definition of done][done]. The single exception is documentation, and this can
|
||||
only be left until after the freeze if:
|
||||
|
||||
* There is a follow-up issue to add documentation.
|
||||
* It is assigned to the person writing documentation for this feature, and they
|
||||
are aware of it.
|
||||
* It is in the correct milestone, with the ~Deliverable label.
|
||||
|
||||
All Community Edition merge requests from GitLab team members merged on the
|
||||
freeze date (the 7th) should have a corresponding Enterprise Edition merge
|
||||
request, even if there are no conflicts. This is to reduce the size of the
|
||||
subsequent EE merge, as we often merge a lot to CE on the release date. For more
|
||||
information, see
|
||||
[limit conflicts with EE when developing on CE][limit_ee_conflicts].
|
||||
|
||||
### Between the 7th and the 22nd
|
||||
|
||||
Once the stable branch is frozen, only fixes for regressions (bugs introduced in that same release)
|
||||
and security issues will be cherry-picked into the stable branch.
|
||||
Any merge requests cherry-picked into the stable branch for a previous release will also be picked into the latest stable branch.
|
||||
|
@ -158,3 +201,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http
|
|||
[contribution acceptance criteria]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#contribution-acceptance-criteria
|
||||
["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements
|
||||
[Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review
|
||||
[done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done
|
||||
[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html
|
||||
|
|
|
@ -263,7 +263,8 @@ AwardsHandler.prototype.addAward = function addAward(
|
|||
this.addAwardToEmojiBar(votesBlock, normalizedEmoji, checkMutuality);
|
||||
return typeof callback === 'function' ? callback() : undefined;
|
||||
});
|
||||
return $('.emoji-menu').removeClass('is-visible');
|
||||
$('.emoji-menu').removeClass('is-visible');
|
||||
$('.js-add-award.is-active').removeClass('is-active');
|
||||
};
|
||||
|
||||
AwardsHandler.prototype.addAwardToEmojiBar = function addAwardToEmojiBar(
|
||||
|
|
15
app/assets/javascripts/blob/blob_fork_suggestion.js
Normal file
|
@ -0,0 +1,15 @@
|
|||
function BlobForkSuggestion(openButton, cancelButton, suggestionSection) {
|
||||
if (openButton) {
|
||||
openButton.addEventListener('click', () => {
|
||||
suggestionSection.classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
if (cancelButton) {
|
||||
cancelButton.addEventListener('click', () => {
|
||||
suggestionSection.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default BlobForkSuggestion;
|
|
@ -84,10 +84,10 @@ window.Build = (function() {
|
|||
var removeRefreshStatuses = ['success', 'failed', 'canceled', 'skipped'];
|
||||
|
||||
return $.ajax({
|
||||
url: this.buildUrl,
|
||||
url: this.pageUrl + "/trace.json",
|
||||
dataType: 'json',
|
||||
success: function(buildData) {
|
||||
$('.js-build-output').html(buildData.trace_html);
|
||||
$('.js-build-output').html(buildData.html);
|
||||
gl.utils.setCiStatusFavicon(`${this.pageUrl}/status.json`);
|
||||
if (window.location.hash === DOWN_BUILD_TRACE) {
|
||||
$("html,body").scrollTop(this.$buildTrace.height());
|
||||
|
|
|
@ -43,6 +43,7 @@ import GroupsList from './groups_list';
|
|||
import ProjectsList from './projects_list';
|
||||
import MiniPipelineGraph from './mini_pipeline_graph_dropdown';
|
||||
import BlobLinePermalinkUpdater from './blob/blob_line_permalink_updater';
|
||||
import BlobForkSuggestion from './blob/blob_fork_suggestion';
|
||||
import UserCallout from './user_callout';
|
||||
|
||||
const ShortcutsBlob = require('./shortcuts_blob');
|
||||
|
@ -86,6 +87,12 @@ const ShortcutsBlob = require('./shortcuts_blob');
|
|||
skipResetBindings: true,
|
||||
fileBlobPermalinkUrl,
|
||||
});
|
||||
|
||||
new BlobForkSuggestion(
|
||||
document.querySelector('.js-edit-blob-link-fork-toggler'),
|
||||
document.querySelector('.js-cancel-fork-suggestion'),
|
||||
document.querySelector('.js-file-fork-suggestion-section'),
|
||||
);
|
||||
}
|
||||
|
||||
switch (page) {
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
import eventHub from '../event_hub';
|
||||
|
||||
export default {
|
||||
name: 'RecentSearchesDropdownContent',
|
||||
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
processedItems() {
|
||||
return this.items.map((item) => {
|
||||
const { tokens, searchToken }
|
||||
= gl.FilteredSearchTokenizer.processTokens(item);
|
||||
|
||||
const resultantTokens = tokens.map(token => ({
|
||||
prefix: `${token.key}:`,
|
||||
suffix: `${token.symbol}${token.value}`,
|
||||
}));
|
||||
|
||||
return {
|
||||
text: item,
|
||||
tokens: resultantTokens,
|
||||
searchToken,
|
||||
};
|
||||
});
|
||||
},
|
||||
hasItems() {
|
||||
return this.items.length > 0;
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onItemActivated(text) {
|
||||
eventHub.$emit('recentSearchesItemSelected', text);
|
||||
},
|
||||
onRequestClearRecentSearches(e) {
|
||||
// Stop the dropdown from closing
|
||||
e.stopPropagation();
|
||||
|
||||
eventHub.$emit('requestClearRecentSearches');
|
||||
},
|
||||
},
|
||||
|
||||
template: `
|
||||
<div>
|
||||
<ul v-if="hasItems">
|
||||
<li
|
||||
v-for="(item, index) in processedItems"
|
||||
:key="index">
|
||||
<button
|
||||
type="button"
|
||||
class="filtered-search-history-dropdown-item"
|
||||
@click="onItemActivated(item.text)">
|
||||
<span>
|
||||
<span
|
||||
v-for="(token, tokenIndex) in item.tokens"
|
||||
class="filtered-search-history-dropdown-token">
|
||||
<span class="name">{{ token.prefix }}</span><span class="value">{{ token.suffix }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<span class="filtered-search-history-dropdown-search-token">
|
||||
{{ item.searchToken }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
<li class="divider"></li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="filtered-search-history-clear-button"
|
||||
@click="onRequestClearRecentSearches($event)">
|
||||
Clear recent searches
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
v-else
|
||||
class="dropdown-info-note">
|
||||
You don't have any recent searches
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
};
|
|
@ -56,7 +56,7 @@ require('./filtered_search_dropdown');
|
|||
renderContent() {
|
||||
const dropdownData = [];
|
||||
|
||||
[].forEach.call(this.input.closest('.filtered-search-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
|
||||
[].forEach.call(this.input.closest('.filtered-search-box-input-container').querySelectorAll('.dropdown-menu'), (dropdownMenu) => {
|
||||
const { icon, hint, tag, type } = dropdownMenu.dataset;
|
||||
if (icon && hint && tag) {
|
||||
dropdownData.push(
|
||||
|
|
|
@ -129,7 +129,9 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
});
|
||||
|
||||
return values.join(' ');
|
||||
return values
|
||||
.map(value => value.trim())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
static getSearchInput(filteredSearchInput) {
|
||||
|
|
3
app/assets/javascripts/filtered_search/event_hub.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Vue from 'vue';
|
||||
|
||||
export default new Vue();
|
|
@ -1,18 +1,56 @@
|
|||
/* global Flash */
|
||||
|
||||
import FilteredSearchContainer from './container';
|
||||
import RecentSearchesRoot from './recent_searches_root';
|
||||
import RecentSearchesStore from './stores/recent_searches_store';
|
||||
import RecentSearchesService from './services/recent_searches_service';
|
||||
import eventHub from './event_hub';
|
||||
|
||||
(() => {
|
||||
class FilteredSearchManager {
|
||||
constructor(page) {
|
||||
this.container = FilteredSearchContainer.container;
|
||||
this.filteredSearchInput = this.container.querySelector('.filtered-search');
|
||||
this.filteredSearchInputForm = this.filteredSearchInput.form;
|
||||
this.clearSearchButton = this.container.querySelector('.clear-search');
|
||||
this.tokensContainer = this.container.querySelector('.tokens-container');
|
||||
this.filteredSearchTokenKeys = gl.FilteredSearchTokenKeys;
|
||||
|
||||
this.recentSearchesStore = new RecentSearchesStore();
|
||||
let recentSearchesKey = 'issue-recent-searches';
|
||||
if (page === 'merge_requests') {
|
||||
recentSearchesKey = 'merge-request-recent-searches';
|
||||
}
|
||||
this.recentSearchesService = new RecentSearchesService(recentSearchesKey);
|
||||
|
||||
// Fetch recent searches from localStorage
|
||||
this.fetchingRecentSearchesPromise = this.recentSearchesService.fetch()
|
||||
.catch(() => {
|
||||
// eslint-disable-next-line no-new
|
||||
new Flash('An error occured while parsing recent searches');
|
||||
// Gracefully fail to empty array
|
||||
return [];
|
||||
})
|
||||
.then((searches) => {
|
||||
// Put any searches that may have come in before
|
||||
// we fetched the saved searches ahead of the already saved ones
|
||||
const resultantSearches = this.recentSearchesStore.setRecentSearches(
|
||||
this.recentSearchesStore.state.recentSearches.concat(searches),
|
||||
);
|
||||
this.recentSearchesService.save(resultantSearches);
|
||||
});
|
||||
|
||||
if (this.filteredSearchInput) {
|
||||
this.tokenizer = gl.FilteredSearchTokenizer;
|
||||
this.dropdownManager = new gl.FilteredSearchDropdownManager(this.filteredSearchInput.getAttribute('data-base-endpoint') || '', page);
|
||||
|
||||
this.recentSearchesRoot = new RecentSearchesRoot(
|
||||
this.recentSearchesStore,
|
||||
this.recentSearchesService,
|
||||
document.querySelector('.js-filtered-search-history-dropdown'),
|
||||
);
|
||||
this.recentSearchesRoot.init();
|
||||
|
||||
this.bindEvents();
|
||||
this.loadSearchParamsFromURL();
|
||||
this.dropdownManager.setDropdown();
|
||||
|
@ -25,6 +63,10 @@ import FilteredSearchContainer from './container';
|
|||
cleanup() {
|
||||
this.unbindEvents();
|
||||
document.removeEventListener('beforeunload', this.cleanupWrapper);
|
||||
|
||||
if (this.recentSearchesRoot) {
|
||||
this.recentSearchesRoot.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
|
@ -34,7 +76,7 @@ import FilteredSearchContainer from './container';
|
|||
this.handleInputPlaceholderWrapper = this.handleInputPlaceholder.bind(this);
|
||||
this.handleInputVisualTokenWrapper = this.handleInputVisualToken.bind(this);
|
||||
this.checkForEnterWrapper = this.checkForEnter.bind(this);
|
||||
this.clearSearchWrapper = this.clearSearch.bind(this);
|
||||
this.onClearSearchWrapper = this.onClearSearch.bind(this);
|
||||
this.checkForBackspaceWrapper = this.checkForBackspace.bind(this);
|
||||
this.removeSelectedTokenWrapper = this.removeSelectedToken.bind(this);
|
||||
this.unselectEditTokensWrapper = this.unselectEditTokens.bind(this);
|
||||
|
@ -42,8 +84,8 @@ import FilteredSearchContainer from './container';
|
|||
this.tokenChange = this.tokenChange.bind(this);
|
||||
this.addInputContainerFocusWrapper = this.addInputContainerFocus.bind(this);
|
||||
this.removeInputContainerFocusWrapper = this.removeInputContainerFocus.bind(this);
|
||||
this.onrecentSearchesItemSelectedWrapper = this.onrecentSearchesItemSelected.bind(this);
|
||||
|
||||
this.filteredSearchInputForm = this.filteredSearchInput.form;
|
||||
this.filteredSearchInputForm.addEventListener('submit', this.handleFormSubmit);
|
||||
this.filteredSearchInput.addEventListener('input', this.setDropdownWrapper);
|
||||
this.filteredSearchInput.addEventListener('input', this.toggleClearSearchButtonWrapper);
|
||||
|
@ -56,11 +98,12 @@ import FilteredSearchContainer from './container';
|
|||
this.filteredSearchInput.addEventListener('focus', this.addInputContainerFocusWrapper);
|
||||
this.tokensContainer.addEventListener('click', FilteredSearchManager.selectToken);
|
||||
this.tokensContainer.addEventListener('dblclick', this.editTokenWrapper);
|
||||
this.clearSearchButton.addEventListener('click', this.clearSearchWrapper);
|
||||
this.clearSearchButton.addEventListener('click', this.onClearSearchWrapper);
|
||||
document.addEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
|
||||
document.addEventListener('click', this.unselectEditTokensWrapper);
|
||||
document.addEventListener('click', this.removeInputContainerFocusWrapper);
|
||||
document.addEventListener('keydown', this.removeSelectedTokenWrapper);
|
||||
eventHub.$on('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
|
||||
}
|
||||
|
||||
unbindEvents() {
|
||||
|
@ -76,11 +119,12 @@ import FilteredSearchContainer from './container';
|
|||
this.filteredSearchInput.removeEventListener('focus', this.addInputContainerFocusWrapper);
|
||||
this.tokensContainer.removeEventListener('click', FilteredSearchManager.selectToken);
|
||||
this.tokensContainer.removeEventListener('dblclick', this.editTokenWrapper);
|
||||
this.clearSearchButton.removeEventListener('click', this.clearSearchWrapper);
|
||||
this.clearSearchButton.removeEventListener('click', this.onClearSearchWrapper);
|
||||
document.removeEventListener('click', gl.FilteredSearchVisualTokens.unselectTokens);
|
||||
document.removeEventListener('click', this.unselectEditTokensWrapper);
|
||||
document.removeEventListener('click', this.removeInputContainerFocusWrapper);
|
||||
document.removeEventListener('keydown', this.removeSelectedTokenWrapper);
|
||||
eventHub.$off('recentSearchesItemSelected', this.onrecentSearchesItemSelectedWrapper);
|
||||
}
|
||||
|
||||
checkForBackspace(e) {
|
||||
|
@ -131,7 +175,7 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
|
||||
addInputContainerFocus() {
|
||||
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
|
||||
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
|
||||
|
||||
if (inputContainer) {
|
||||
inputContainer.classList.add('focus');
|
||||
|
@ -139,7 +183,7 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
|
||||
removeInputContainerFocus(e) {
|
||||
const inputContainer = this.filteredSearchInput.closest('.filtered-search-input-container');
|
||||
const inputContainer = this.filteredSearchInput.closest('.filtered-search-box');
|
||||
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
|
||||
const isElementInDynamicFilterDropdown = e.target.closest('.filter-dropdown') !== null;
|
||||
const isElementInStaticFilterDropdown = e.target.closest('ul[data-dropdown]') !== null;
|
||||
|
@ -161,7 +205,7 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
|
||||
unselectEditTokens(e) {
|
||||
const inputContainer = this.container.querySelector('.filtered-search-input-container');
|
||||
const inputContainer = this.container.querySelector('.filtered-search-box');
|
||||
const isElementInFilteredSearch = inputContainer && inputContainer.contains(e.target);
|
||||
const isElementInFilterDropdown = e.target.closest('.filter-dropdown') !== null;
|
||||
const isElementTokensContainer = e.target.classList.contains('tokens-container');
|
||||
|
@ -215,9 +259,12 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
}
|
||||
|
||||
clearSearch(e) {
|
||||
onClearSearch(e) {
|
||||
e.preventDefault();
|
||||
this.clearSearch();
|
||||
}
|
||||
|
||||
clearSearch() {
|
||||
this.filteredSearchInput.value = '';
|
||||
|
||||
const removeElements = [];
|
||||
|
@ -289,6 +336,17 @@ import FilteredSearchContainer from './container';
|
|||
this.search();
|
||||
}
|
||||
|
||||
saveCurrentSearchQuery() {
|
||||
// Don't save before we have fetched the already saved searches
|
||||
this.fetchingRecentSearchesPromise.then(() => {
|
||||
const searchQuery = gl.DropdownUtils.getSearchQuery();
|
||||
if (searchQuery.length > 0) {
|
||||
const resultantSearches = this.recentSearchesStore.addRecentSearch(searchQuery);
|
||||
this.recentSearchesService.save(resultantSearches);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadSearchParamsFromURL() {
|
||||
const params = gl.utils.getUrlParamsArray();
|
||||
const usernameParams = this.getUsernameParams();
|
||||
|
@ -343,6 +401,8 @@ import FilteredSearchContainer from './container';
|
|||
}
|
||||
});
|
||||
|
||||
this.saveCurrentSearchQuery();
|
||||
|
||||
if (hasFilteredSearch) {
|
||||
this.clearSearchButton.classList.remove('hidden');
|
||||
this.handleInputPlaceholder();
|
||||
|
@ -351,8 +411,12 @@ import FilteredSearchContainer from './container';
|
|||
|
||||
search() {
|
||||
const paths = [];
|
||||
const searchQuery = gl.DropdownUtils.getSearchQuery();
|
||||
|
||||
this.saveCurrentSearchQuery();
|
||||
|
||||
const { tokens, searchToken }
|
||||
= this.tokenizer.processTokens(gl.DropdownUtils.getSearchQuery());
|
||||
= this.tokenizer.processTokens(searchQuery);
|
||||
const currentState = gl.utils.getParameterByName('state') || 'opened';
|
||||
paths.push(`state=${currentState}`);
|
||||
|
||||
|
@ -416,6 +480,13 @@ import FilteredSearchContainer from './container';
|
|||
currentDropdownRef.dispatchInputEvent();
|
||||
}
|
||||
}
|
||||
|
||||
onrecentSearchesItemSelected(text) {
|
||||
this.clearSearch();
|
||||
this.filteredSearchInput.value = text;
|
||||
this.filteredSearchInput.dispatchEvent(new CustomEvent('input'));
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
window.gl = window.gl || {};
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
import Vue from 'vue';
|
||||
import RecentSearchesDropdownContent from './components/recent_searches_dropdown_content';
|
||||
import eventHub from './event_hub';
|
||||
|
||||
class RecentSearchesRoot {
|
||||
constructor(
|
||||
recentSearchesStore,
|
||||
recentSearchesService,
|
||||
wrapperElement,
|
||||
) {
|
||||
this.store = recentSearchesStore;
|
||||
this.service = recentSearchesService;
|
||||
this.wrapperElement = wrapperElement;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.bindEvents();
|
||||
this.render();
|
||||
}
|
||||
|
||||
bindEvents() {
|
||||
this.onRequestClearRecentSearchesWrapper = this.onRequestClearRecentSearches.bind(this);
|
||||
|
||||
eventHub.$on('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
|
||||
}
|
||||
|
||||
unbindEvents() {
|
||||
eventHub.$off('requestClearRecentSearches', this.onRequestClearRecentSearchesWrapper);
|
||||
}
|
||||
|
||||
render() {
|
||||
this.vm = new Vue({
|
||||
el: this.wrapperElement,
|
||||
data: this.store.state,
|
||||
template: `
|
||||
<recent-searches-dropdown-content
|
||||
:items="recentSearches" />
|
||||
`,
|
||||
components: {
|
||||
'recent-searches-dropdown-content': RecentSearchesDropdownContent,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onRequestClearRecentSearches() {
|
||||
const resultantSearches = this.store.setRecentSearches([]);
|
||||
this.service.save(resultantSearches);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unbindEvents();
|
||||
if (this.vm) {
|
||||
this.vm.$destroy();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default RecentSearchesRoot;
|
|
@ -0,0 +1,26 @@
|
|||
class RecentSearchesService {
|
||||
constructor(localStorageKey = 'issuable-recent-searches') {
|
||||
this.localStorageKey = localStorageKey;
|
||||
}
|
||||
|
||||
fetch() {
|
||||
const input = window.localStorage.getItem(this.localStorageKey);
|
||||
|
||||
let searches = [];
|
||||
if (input && input.length > 0) {
|
||||
try {
|
||||
searches = JSON.parse(input);
|
||||
} catch (err) {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(searches);
|
||||
}
|
||||
|
||||
save(searches = []) {
|
||||
window.localStorage.setItem(this.localStorageKey, JSON.stringify(searches));
|
||||
}
|
||||
}
|
||||
|
||||
export default RecentSearchesService;
|
|
@ -0,0 +1,23 @@
|
|||
import _ from 'underscore';
|
||||
|
||||
class RecentSearchesStore {
|
||||
constructor(initialState = {}) {
|
||||
this.state = Object.assign({
|
||||
recentSearches: [],
|
||||
}, initialState);
|
||||
}
|
||||
|
||||
addRecentSearch(newSearch) {
|
||||
this.setRecentSearches([newSearch].concat(this.state.recentSearches));
|
||||
|
||||
return this.state.recentSearches;
|
||||
}
|
||||
|
||||
setRecentSearches(searches = []) {
|
||||
const trimmedSearches = searches.map(search => search.trim());
|
||||
this.state.recentSearches = _.uniq(trimmedSearches).slice(0, 5);
|
||||
return this.state.recentSearches;
|
||||
}
|
||||
}
|
||||
|
||||
export default RecentSearchesStore;
|
|
@ -6,7 +6,10 @@ import statusCodes from '~/lib/utils/http_status';
|
|||
import { formatRelevantDigits } from '~/lib/utils/number_utils';
|
||||
import '../flash';
|
||||
|
||||
const prometheusContainer = '.prometheus-container';
|
||||
const prometheusParentGraphContainer = '.prometheus-graphs';
|
||||
const prometheusGraphsContainer = '.prometheus-graph';
|
||||
const prometheusStatesContainer = '.prometheus-state';
|
||||
const metricsEndpoint = 'metrics.json';
|
||||
const timeFormat = d3.time.format('%H:%M');
|
||||
const dayFormat = d3.time.format('%b %e, %a');
|
||||
|
@ -14,19 +17,30 @@ const bisectDate = d3.bisector(d => d.time).left;
|
|||
const extraAddedWidthParent = 100;
|
||||
|
||||
class PrometheusGraph {
|
||||
|
||||
constructor() {
|
||||
this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
|
||||
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
|
||||
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
|
||||
extraAddedWidthParent;
|
||||
this.originalWidth = parentContainerWidth;
|
||||
this.originalHeight = 330;
|
||||
this.width = parentContainerWidth - this.margin.left - this.margin.right;
|
||||
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
|
||||
this.backOffRequestCounter = 0;
|
||||
this.configureGraph();
|
||||
this.init();
|
||||
const $prometheusContainer = $(prometheusContainer);
|
||||
const hasMetrics = $prometheusContainer.data('has-metrics');
|
||||
this.docLink = $prometheusContainer.data('doc-link');
|
||||
this.integrationLink = $prometheusContainer.data('prometheus-integration');
|
||||
|
||||
$(document).ajaxError(() => {});
|
||||
|
||||
if (hasMetrics) {
|
||||
this.margin = { top: 80, right: 180, bottom: 80, left: 100 };
|
||||
this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 };
|
||||
const parentContainerWidth = $(prometheusGraphsContainer).parent().width() +
|
||||
extraAddedWidthParent;
|
||||
this.originalWidth = parentContainerWidth;
|
||||
this.originalHeight = 330;
|
||||
this.width = parentContainerWidth - this.margin.left - this.margin.right;
|
||||
this.height = this.originalHeight - this.margin.top - this.margin.bottom;
|
||||
this.backOffRequestCounter = 0;
|
||||
this.configureGraph();
|
||||
this.init();
|
||||
} else {
|
||||
this.state = '.js-getting-started';
|
||||
this.updateState();
|
||||
}
|
||||
}
|
||||
|
||||
createGraph() {
|
||||
|
@ -40,8 +54,19 @@ class PrometheusGraph {
|
|||
|
||||
init() {
|
||||
this.getData().then((metricsResponse) => {
|
||||
if (Object.keys(metricsResponse).length === 0) {
|
||||
new Flash('Empty metrics', 'alert');
|
||||
let enoughData = true;
|
||||
Object.keys(metricsResponse.metrics).forEach((key) => {
|
||||
let currentKey;
|
||||
if (key === 'cpu_values' || key === 'memory_values') {
|
||||
currentKey = metricsResponse.metrics[key];
|
||||
if (Object.keys(currentKey).length === 0) {
|
||||
enoughData = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!enoughData) {
|
||||
this.state = '.js-loading';
|
||||
this.updateState();
|
||||
} else {
|
||||
this.transformData(metricsResponse);
|
||||
this.createGraph();
|
||||
|
@ -345,14 +370,17 @@ class PrometheusGraph {
|
|||
}
|
||||
return resp.metrics;
|
||||
})
|
||||
.catch(() => new Flash('An error occurred while fetching metrics.', 'alert'));
|
||||
.catch(() => {
|
||||
this.state = '.js-unable-to-connect';
|
||||
this.updateState();
|
||||
});
|
||||
}
|
||||
|
||||
transformData(metricsResponse) {
|
||||
Object.keys(metricsResponse.metrics).forEach((key) => {
|
||||
if (key === 'cpu_values' || key === 'memory_values') {
|
||||
const metricValues = (metricsResponse.metrics[key])[0];
|
||||
if (typeof metricValues !== 'undefined') {
|
||||
if (metricValues !== undefined) {
|
||||
this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
|
||||
time: new Date(metric[0] * 1000),
|
||||
value: metric[1],
|
||||
|
@ -361,6 +389,13 @@ class PrometheusGraph {
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateState() {
|
||||
const $statesContainer = $(prometheusStatesContainer);
|
||||
$(prometheusParentGraphContainer).hide();
|
||||
$(`${this.state}`, $statesContainer).removeClass('hidden');
|
||||
$(prometheusStatesContainer).show();
|
||||
}
|
||||
}
|
||||
|
||||
export default PrometheusGraph;
|
||||
|
|
|
@ -91,7 +91,7 @@
|
|||
|
||||
.award-menu-holder {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
|
||||
.tooltip {
|
||||
white-space: nowrap;
|
||||
|
@ -117,11 +117,41 @@
|
|||
|
||||
&.active,
|
||||
&:hover,
|
||||
&:active {
|
||||
&:active,
|
||||
&.is-active {
|
||||
background-color: $row-hover;
|
||||
border-color: $row-hover-border;
|
||||
box-shadow: none;
|
||||
outline: 0;
|
||||
|
||||
.award-control-icon svg {
|
||||
background: $award-emoji-positive-add-bg;
|
||||
|
||||
path {
|
||||
fill: $award-emoji-positive-add-lines;
|
||||
}
|
||||
}
|
||||
|
||||
.award-control-icon-neutral {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.award-control-icon-positive {
|
||||
opacity: 1;
|
||||
transform: scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.award-control-icon-positive {
|
||||
opacity: 0;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.award-control-icon-super-positive {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.btn {
|
||||
|
@ -162,9 +192,33 @@
|
|||
color: $border-gray-normal;
|
||||
margin-top: 1px;
|
||||
padding: 0 2px;
|
||||
|
||||
svg {
|
||||
margin-bottom: 1px;
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
border-radius: 50%;
|
||||
|
||||
path {
|
||||
fill: $border-gray-normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.award-control-icon-positive,
|
||||
.award-control-icon-super-positive {
|
||||
position: absolute;
|
||||
left: 7px;
|
||||
bottom: 9px;
|
||||
opacity: 0;
|
||||
@include transition(opacity, transform);
|
||||
}
|
||||
|
||||
.award-control-text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.note-awards .award-control-icon-positive {
|
||||
left: 6px;
|
||||
}
|
||||
|
|
|
@ -177,10 +177,6 @@
|
|||
border-radius: $border-radius-base;
|
||||
box-shadow: 0 2px 4px $dropdown-shadow-color;
|
||||
|
||||
.filtered-search-input-container & {
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
&.is-loading {
|
||||
.dropdown-content {
|
||||
display: none;
|
||||
|
@ -467,6 +463,11 @@
|
|||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-info-note {
|
||||
color: $gl-text-color-secondary;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-footer {
|
||||
padding-top: 10px;
|
||||
margin-top: 10px;
|
||||
|
|
|
@ -281,3 +281,16 @@ span.idiff {
|
|||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.file-fork-suggestion {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
background-color: $gray-light;
|
||||
border-bottom: 1px solid $border-color;
|
||||
padding: 5px $gl-padding;
|
||||
}
|
||||
|
||||
.file-fork-suggestion-note {
|
||||
margin-right: 1.5em;
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@
|
|||
}
|
||||
|
||||
@media (min-width: $screen-sm-min) {
|
||||
.issues-filters,
|
||||
.issues_bulk_update {
|
||||
.dropdown-menu-toggle {
|
||||
width: 132px;
|
||||
|
@ -56,7 +55,7 @@
|
|||
}
|
||||
}
|
||||
|
||||
.filtered-search-container {
|
||||
.filtered-search-wrapper {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
|
||||
|
@ -151,11 +150,13 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.filtered-search-input-container {
|
||||
.filtered-search-box {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid $border-color;
|
||||
background-color: $white-light;
|
||||
|
||||
|
@ -163,14 +164,6 @@
|
|||
-webkit-flex: 1 1 auto;
|
||||
flex: 1 1 auto;
|
||||
margin-bottom: 10px;
|
||||
|
||||
.dropdown-menu {
|
||||
width: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
|
@ -229,6 +222,118 @@
|
|||
}
|
||||
}
|
||||
|
||||
.filtered-search-box-input-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
// Fix PhantomJS not supporting `flex: 1;` properly.
|
||||
// This is important because it can change the expected `e.target` when clicking things in tests.
|
||||
// See https://gitlab.com/gitlab-org/gitlab-ce/blob/b54acba8b732688c59fe2f38510c469dc86ee499/spec/features/issues/filtered_search/visual_tokens_spec.rb#L61
|
||||
// - With `width: 100%`: `e.target` = `.tokens-container`, https://i.imgur.com/jGq7wbx.png
|
||||
// - Without `width: 100%`: `e.target` = `.filtered-search`, https://i.imgur.com/cNI2CyT.png
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.filtered-search-input-dropdown-menu {
|
||||
max-width: 280px;
|
||||
|
||||
@media (max-width: $screen-xs-min) {
|
||||
width: auto;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown-toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
height: 100%;
|
||||
padding-top: 0;
|
||||
padding-left: 0.75em;
|
||||
padding-bottom: 0;
|
||||
padding-right: 0.5em;
|
||||
|
||||
background-color: transparent;
|
||||
border-radius: 0;
|
||||
border-top: 0;
|
||||
border-left: 0;
|
||||
border-bottom: 0;
|
||||
border-right: 1px solid $border-color;
|
||||
|
||||
color: $gl-text-color-secondary;
|
||||
|
||||
transition: color 0.1s linear;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $gl-text-color;
|
||||
border-color: $dropdown-input-focus-border;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dropdown-toggle-text {
|
||||
color: inherit;
|
||||
|
||||
.fa {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.fa {
|
||||
position: initial;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown-wrapper {
|
||||
position: initial;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown {
|
||||
width: 40%;
|
||||
|
||||
@media (max-width: $screen-xs-min) {
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown-content {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown-item,
|
||||
.filtered-search-history-clear-button {
|
||||
@include dropdown-link;
|
||||
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
margin: 0.5em 0;
|
||||
|
||||
background-color: transparent;
|
||||
border: 0;
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.filtered-search-history-dropdown-token {
|
||||
display: inline;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-right: 0.3em;
|
||||
}
|
||||
|
||||
& > .value {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-dropdown-container {
|
||||
display: -webkit-flex;
|
||||
display: flex;
|
||||
|
@ -248,10 +353,8 @@
|
|||
}
|
||||
|
||||
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
|
||||
.issues-details-filters {
|
||||
.dropdown-menu-toggle {
|
||||
width: 100px;
|
||||
}
|
||||
.issue-bulk-update-dropdown-toggle {
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,6 +16,8 @@ body.modal-open {
|
|||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal .modal-dialog {
|
||||
width: 860px;
|
||||
@media (min-width: $screen-md-min) {
|
||||
.modal-dialog {
|
||||
width: 860px;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -293,6 +293,8 @@ $badge-color: $gl-text-color-secondary;
|
|||
* Award emoji
|
||||
*/
|
||||
$award-emoji-menu-shadow: rgba(0,0,0,.175);
|
||||
$award-emoji-positive-add-bg: #fed159;
|
||||
$award-emoji-positive-add-lines: #bb9c13;
|
||||
|
||||
/*
|
||||
* Search Box
|
||||
|
|
16
app/assets/stylesheets/pages/container_registry.scss
Normal file
|
@ -0,0 +1,16 @@
|
|||
/**
|
||||
* Container Registry
|
||||
*/
|
||||
|
||||
.container-image {
|
||||
border-bottom: 1px solid $white-normal;
|
||||
}
|
||||
|
||||
.container-image-head {
|
||||
padding: 0 16px;
|
||||
line-height: 4em;
|
||||
}
|
||||
|
||||
.table.tags {
|
||||
margin-bottom: 0;
|
||||
}
|
|
@ -233,6 +233,15 @@
|
|||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.prometheus-state {
|
||||
margin-top: 10px;
|
||||
display: none;
|
||||
|
||||
.state-button-section {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.environments-actions {
|
||||
.external-url,
|
||||
.monitoring-url,
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
*/
|
||||
.event-item {
|
||||
font-size: $gl-font-size;
|
||||
padding: $gl-padding-top 0 $gl-padding-top ($gl-avatar-size + $gl-padding-top);
|
||||
padding: $gl-padding-top 0 $gl-padding-top 40px;
|
||||
border-bottom: 1px solid $white-normal;
|
||||
color: $list-text-color;
|
||||
position: relative;
|
||||
|
||||
&.event-inline {
|
||||
.avatar {
|
||||
position: relative;
|
||||
top: -2px;
|
||||
.profile-icon {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
.event-title,
|
||||
|
@ -24,8 +24,28 @@
|
|||
color: $gl-text-color;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-left: -($gl-avatar-size + $gl-padding-top);
|
||||
.profile-icon {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 14px;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: auto;
|
||||
fill: $gl-text-color-secondary;
|
||||
}
|
||||
|
||||
&.open-icon svg {
|
||||
fill: $green-300;
|
||||
}
|
||||
|
||||
&.closed-icon svg {
|
||||
fill: $red-300;
|
||||
}
|
||||
|
||||
&.fork-icon svg {
|
||||
fill: $blue-300;
|
||||
}
|
||||
}
|
||||
|
||||
.event-title {
|
||||
|
@ -163,7 +183,7 @@
|
|||
max-width: 100%;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
.profile-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
|
|
@ -329,8 +329,6 @@
|
|||
}
|
||||
|
||||
#modal_merge_info .modal-dialog {
|
||||
width: 600px;
|
||||
|
||||
.dark {
|
||||
margin-right: 40px;
|
||||
}
|
||||
|
|
|
@ -398,13 +398,50 @@ ul.notes {
|
|||
font-size: 17px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
fill: $gray-darkest;
|
||||
vertical-align: text-top;
|
||||
}
|
||||
|
||||
.award-control-icon-positive,
|
||||
.award-control-icon-super-positive {
|
||||
position: absolute;
|
||||
margin-left: -20px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.is-active {
|
||||
.danger-highlight {
|
||||
color: $gl-text-red;
|
||||
}
|
||||
|
||||
.link-highlight {
|
||||
color: $gl-link-color;
|
||||
|
||||
svg {
|
||||
fill: $gl-link-color;
|
||||
}
|
||||
}
|
||||
|
||||
.award-control-icon-neutral {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.award-control-icon-positive {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.award-control-icon-positive {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.award-control-icon-super-positive {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -508,7 +545,6 @@ ul.notes {
|
|||
}
|
||||
|
||||
.line-resolve-all-container {
|
||||
|
||||
.btn-group {
|
||||
margin-left: -4px;
|
||||
}
|
||||
|
@ -537,7 +573,6 @@ ul.notes {
|
|||
fill: $gray-darkest;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.line-resolve-all {
|
||||
|
|
|
@ -230,6 +230,14 @@
|
|||
font-size: 0;
|
||||
}
|
||||
|
||||
.fade-right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fade-left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: $screen-xs-max) {
|
||||
.cover-block {
|
||||
padding-top: 20px;
|
||||
|
|
|
@ -6,6 +6,8 @@
|
|||
}
|
||||
|
||||
.trigger-actions {
|
||||
white-space: nowrap;
|
||||
|
||||
.btn {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
|
|
@ -145,8 +145,6 @@
|
|||
margin: 0;
|
||||
}
|
||||
|
||||
#modal-remove-blob > .modal-dialog { width: 850px; }
|
||||
|
||||
.blob-upload-dropzone-previews {
|
||||
text-align: center;
|
||||
border: 2px;
|
||||
|
|
|
@ -72,7 +72,9 @@ class Admin::GroupsController < Admin::ApplicationController
|
|||
:name,
|
||||
:path,
|
||||
:request_access_enabled,
|
||||
:visibility_level
|
||||
:visibility_level,
|
||||
:require_two_factor_authentication,
|
||||
:two_factor_grace_period
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,12 +8,12 @@ class ApplicationController < ActionController::Base
|
|||
include PageLayoutHelper
|
||||
include SentryHelper
|
||||
include WorkhorseHelper
|
||||
include EnforcesTwoFactorAuthentication
|
||||
|
||||
before_action :authenticate_user_from_private_token!
|
||||
before_action :authenticate_user!
|
||||
before_action :validate_user_service_ticket!
|
||||
before_action :check_password_expiration
|
||||
before_action :check_2fa_requirement
|
||||
before_action :ldap_security_check
|
||||
before_action :sentry_context
|
||||
before_action :default_headers
|
||||
|
@ -151,12 +151,6 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
end
|
||||
|
||||
def check_2fa_requirement
|
||||
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
|
||||
redirect_to profile_two_factor_auth_path
|
||||
end
|
||||
end
|
||||
|
||||
def ldap_security_check
|
||||
if current_user && current_user.requires_ldap_check?
|
||||
return unless current_user.try_obtain_ldap_lease
|
||||
|
@ -265,23 +259,6 @@ class ApplicationController < ActionController::Base
|
|||
current_application_settings.import_sources.include?('gitlab_project')
|
||||
end
|
||||
|
||||
def two_factor_authentication_required?
|
||||
current_application_settings.require_two_factor_authentication
|
||||
end
|
||||
|
||||
def two_factor_grace_period
|
||||
current_application_settings.two_factor_grace_period
|
||||
end
|
||||
|
||||
def two_factor_grace_period_expired?
|
||||
date = current_user.otp_grace_period_started_at
|
||||
date && (date + two_factor_grace_period.hours) < Time.current
|
||||
end
|
||||
|
||||
def skip_two_factor?
|
||||
session[:skip_tfa] && session[:skip_tfa] > Time.current
|
||||
end
|
||||
|
||||
# U2F (universal 2nd factor) devices need a unique identifier for the application
|
||||
# to perform authentication.
|
||||
# https://developers.yubico.com/U2F/App_ID.html
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
# == EnforcesTwoFactorAuthentication
|
||||
#
|
||||
# Controller concern to enforce two-factor authentication requirements
|
||||
#
|
||||
# Upon inclusion, adds `check_two_factor_requirement` as a before_action,
|
||||
# and makes `two_factor_grace_period_expired?` and `two_factor_skippable?`
|
||||
# available as view helpers.
|
||||
module EnforcesTwoFactorAuthentication
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
included do
|
||||
before_action :check_two_factor_requirement
|
||||
helper_method :two_factor_grace_period_expired?, :two_factor_skippable?
|
||||
end
|
||||
|
||||
def check_two_factor_requirement
|
||||
if two_factor_authentication_required? && current_user && !current_user.two_factor_enabled? && !skip_two_factor?
|
||||
redirect_to profile_two_factor_auth_path
|
||||
end
|
||||
end
|
||||
|
||||
def two_factor_authentication_required?
|
||||
current_application_settings.require_two_factor_authentication? ||
|
||||
current_user.try(:require_two_factor_authentication_from_group?)
|
||||
end
|
||||
|
||||
def two_factor_authentication_reason(global: -> {}, group: -> {})
|
||||
if two_factor_authentication_required?
|
||||
if current_application_settings.require_two_factor_authentication?
|
||||
global.call
|
||||
else
|
||||
groups = current_user.expanded_groups_requiring_two_factor_authentication.reorder(name: :asc)
|
||||
group.call(groups)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def two_factor_grace_period
|
||||
periods = [current_application_settings.two_factor_grace_period]
|
||||
periods << current_user.two_factor_grace_period if current_user.try(:require_two_factor_authentication_from_group?)
|
||||
periods.min
|
||||
end
|
||||
|
||||
def two_factor_grace_period_expired?
|
||||
date = current_user.otp_grace_period_started_at
|
||||
date && (date + two_factor_grace_period.hours) < Time.current
|
||||
end
|
||||
|
||||
def two_factor_skippable?
|
||||
two_factor_authentication_required? &&
|
||||
!current_user.two_factor_enabled? &&
|
||||
!two_factor_grace_period_expired?
|
||||
end
|
||||
|
||||
def skip_two_factor?
|
||||
session[:skip_two_factor] && session[:skip_two_factor] > Time.current
|
||||
end
|
||||
end
|
|
@ -151,7 +151,9 @@ class GroupsController < Groups::ApplicationController
|
|||
:visibility_level,
|
||||
:parent_id,
|
||||
:create_chat_team,
|
||||
:chat_team_name
|
||||
:chat_team_name,
|
||||
:require_two_factor_authentication,
|
||||
:two_factor_grace_period
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
||||
skip_before_action :check_2fa_requirement
|
||||
skip_before_action :check_two_factor_requirement
|
||||
|
||||
def show
|
||||
unless current_user.otp_secret
|
||||
|
@ -13,11 +13,24 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
current_user.save! if current_user.changed?
|
||||
|
||||
if two_factor_authentication_required? && !current_user.two_factor_enabled?
|
||||
if two_factor_grace_period_expired?
|
||||
flash.now[:alert] = 'You must enable Two-Factor Authentication for your account.'
|
||||
else
|
||||
two_factor_authentication_reason(
|
||||
global: lambda do
|
||||
flash.now[:alert] =
|
||||
'The global settings require you to enable Two-Factor Authentication for your account.'
|
||||
end,
|
||||
group: lambda do |groups|
|
||||
group_links = groups.map { |group| view_context.link_to group.full_name, group_path(group) }.to_sentence
|
||||
|
||||
flash.now[:alert] = %{
|
||||
The group settings for #{group_links} require you to enable
|
||||
Two-Factor Authentication for your account.
|
||||
}.html_safe
|
||||
end
|
||||
)
|
||||
|
||||
unless two_factor_grace_period_expired?
|
||||
grace_period_deadline = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
|
||||
flash.now[:alert] = "You must enable Two-Factor Authentication for your account before #{l(grace_period_deadline)}."
|
||||
flash.now[:alert] << " You need to do this before #{l(grace_period_deadline)}."
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -71,7 +84,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
|
|||
if two_factor_grace_period_expired?
|
||||
redirect_to new_profile_two_factor_auth_path, alert: 'Cannot skip two factor authentication setup'
|
||||
else
|
||||
session[:skip_tfa] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
|
||||
session[:skip_two_factor] = current_user.otp_grace_period_started_at + two_factor_grace_period.hours
|
||||
redirect_to root_path
|
||||
end
|
||||
end
|
||||
|
|
|
@ -7,9 +7,11 @@ class Projects::BlobController < Projects::ApplicationController
|
|||
# Raised when given an invalid file path
|
||||
InvalidPathError = Class.new(StandardError)
|
||||
|
||||
prepend_before_action :authenticate_user!, only: [:edit]
|
||||
|
||||
before_action :require_non_empty_project, except: [:new, :create]
|
||||
before_action :authorize_download_code!
|
||||
before_action :authorize_edit_tree!, only: [:new, :create, :edit, :update, :destroy]
|
||||
before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy]
|
||||
before_action :assign_blob_vars
|
||||
before_action :commit, except: [:new, :create]
|
||||
before_action :blob, except: [:new, :create]
|
||||
|
@ -37,7 +39,11 @@ class Projects::BlobController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def edit
|
||||
blob.load_all_data!(@repository)
|
||||
if can_collaborate_with_project?
|
||||
blob.load_all_data!(@repository)
|
||||
else
|
||||
redirect_to action: 'show'
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
|
|
|
@ -31,25 +31,25 @@ class Projects::BuildsController < Projects::ApplicationController
|
|||
@builds = @project.pipelines.find_by_sha(@build.sha).builds.order('id DESC')
|
||||
@builds = @builds.where("id not in (?)", @build.id)
|
||||
@pipeline = @build.pipeline
|
||||
|
||||
respond_to do |format|
|
||||
format.html
|
||||
format.json do
|
||||
render json: {
|
||||
id: @build.id,
|
||||
status: @build.status,
|
||||
trace_html: @build.trace_html
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def trace
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
state = params[:state].presence
|
||||
render json: @build.trace_with_state(state: state).
|
||||
merge!(id: @build.id, status: @build.status)
|
||||
build.trace.read do |stream|
|
||||
respond_to do |format|
|
||||
format.json do
|
||||
result = {
|
||||
id: @build.id, status: @build.status, complete: @build.complete?
|
||||
}
|
||||
|
||||
if stream.valid?
|
||||
stream.limit
|
||||
state = params[:state].presence
|
||||
trace = stream.html_with_state(state)
|
||||
result.merge!(trace.to_h)
|
||||
end
|
||||
|
||||
render json: result
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -86,10 +86,12 @@ class Projects::BuildsController < Projects::ApplicationController
|
|||
end
|
||||
|
||||
def raw
|
||||
if @build.has_trace_file?
|
||||
send_file @build.trace_file_path, type: 'text/plain; charset=utf-8', disposition: 'inline'
|
||||
else
|
||||
render_404
|
||||
build.trace.read do |stream|
|
||||
if stream.file?
|
||||
send_file stream.path, type: 'text/plain; charset=utf-8', disposition: 'inline'
|
||||
else
|
||||
render_404
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,34 +0,0 @@
|
|||
class Projects::ContainerRegistryController < Projects::ApplicationController
|
||||
before_action :verify_registry_enabled
|
||||
before_action :authorize_read_container_image!
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
layout 'project'
|
||||
|
||||
def index
|
||||
@tags = container_registry_repository.tags
|
||||
end
|
||||
|
||||
def destroy
|
||||
url = namespace_project_container_registry_index_path(project.namespace, project)
|
||||
|
||||
if tag.delete
|
||||
redirect_to url
|
||||
else
|
||||
redirect_to url, alert: 'Failed to remove tag'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def verify_registry_enabled
|
||||
render_404 unless Gitlab.config.registry.enabled
|
||||
end
|
||||
|
||||
def container_registry_repository
|
||||
@container_registry_repository ||= project.container_registry_repository
|
||||
end
|
||||
|
||||
def tag
|
||||
@tag ||= container_registry_repository.tag(params[:id])
|
||||
end
|
||||
end
|
|
@ -452,7 +452,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
|
|||
|
||||
if pipeline
|
||||
status = pipeline.status
|
||||
coverage = pipeline.try(:coverage)
|
||||
coverage = pipeline.coverage
|
||||
|
||||
status = "success_with_warnings" if pipeline.success? && pipeline.has_warnings?
|
||||
|
||||
|
|
16
app/controllers/projects/registry/application_controller.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class ApplicationController < Projects::ApplicationController
|
||||
layout 'project'
|
||||
|
||||
before_action :verify_registry_enabled!
|
||||
before_action :authorize_read_container_image!
|
||||
|
||||
private
|
||||
|
||||
def verify_registry_enabled!
|
||||
render_404 unless Gitlab.config.registry.enabled
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
43
app/controllers/projects/registry/repositories_controller.rb
Normal file
|
@ -0,0 +1,43 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class RepositoriesController < ::Projects::Registry::ApplicationController
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
before_action :ensure_root_container_repository!, only: [:index]
|
||||
|
||||
def index
|
||||
@images = project.container_repositories
|
||||
end
|
||||
|
||||
def destroy
|
||||
if image.destroy
|
||||
redirect_to project_container_registry_path(@project),
|
||||
notice: 'Image repository has been removed successfully!'
|
||||
else
|
||||
redirect_to project_container_registry_path(@project),
|
||||
alert: 'Failed to remove image repository!'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image
|
||||
@image ||= project.container_repositories.find(params[:id])
|
||||
end
|
||||
|
||||
##
|
||||
# Container repository object for root project path.
|
||||
#
|
||||
# Needed to maintain a backwards compatibility.
|
||||
#
|
||||
def ensure_root_container_repository!
|
||||
ContainerRegistry::Path.new(@project.full_path).tap do |path|
|
||||
break if path.has_repository?
|
||||
|
||||
ContainerRepository.build_from_path(path).tap do |repository|
|
||||
repository.save! if repository.has_tags?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
28
app/controllers/projects/registry/tags_controller.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
module Projects
|
||||
module Registry
|
||||
class TagsController < ::Projects::Registry::ApplicationController
|
||||
before_action :authorize_update_container_image!, only: [:destroy]
|
||||
|
||||
def destroy
|
||||
if tag.delete
|
||||
redirect_to project_container_registry_path(@project),
|
||||
notice: 'Registry tag has been removed successfully!'
|
||||
else
|
||||
redirect_to project_container_registry_path(@project),
|
||||
alert: 'Failed to remove registry tag!'
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def image
|
||||
@image ||= project.container_repositories
|
||||
.find(params[:repository_id])
|
||||
end
|
||||
|
||||
def tag
|
||||
@tag ||= image.tag(params[:id])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,7 +3,7 @@ class SessionsController < Devise::SessionsController
|
|||
include Devise::Controllers::Rememberable
|
||||
include Recaptcha::ClientHelper
|
||||
|
||||
skip_before_action :check_2fa_requirement, only: [:destroy]
|
||||
skip_before_action :check_two_factor_requirement, only: [:destroy]
|
||||
|
||||
prepend_before_action :check_initial_setup, only: [:new]
|
||||
prepend_before_action :authenticate_with_two_factor,
|
||||
|
|
|
@ -64,18 +64,6 @@ module AuthHelper
|
|||
current_user.identities.exists?(provider: provider.to_s)
|
||||
end
|
||||
|
||||
def two_factor_skippable?
|
||||
current_application_settings.require_two_factor_authentication &&
|
||||
!current_user.two_factor_enabled? &&
|
||||
current_application_settings.two_factor_grace_period &&
|
||||
!two_factor_grace_period_expired?
|
||||
end
|
||||
|
||||
def two_factor_grace_period_expired?
|
||||
current_user.otp_grace_period_started_at &&
|
||||
(current_user.otp_grace_period_started_at + current_application_settings.two_factor_grace_period.hours) < Time.current
|
||||
end
|
||||
|
||||
def unlink_allowed?(provider)
|
||||
%w(saml cas3).exclude?(provider.to_s)
|
||||
end
|
||||
|
|
|
@ -8,31 +8,36 @@ module BlobHelper
|
|||
%w(credits changelog news copying copyright license authors)
|
||||
end
|
||||
|
||||
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
|
||||
return unless current_user
|
||||
def edit_path(project = @project, ref = @ref, path = @path, options = {})
|
||||
namespace_project_edit_blob_path(project.namespace, project,
|
||||
tree_join(ref, path),
|
||||
options[:link_opts])
|
||||
end
|
||||
|
||||
def fork_path(project = @project, ref = @ref, path = @path, options = {})
|
||||
continue_params = {
|
||||
to: edit_path,
|
||||
notice: edit_in_new_fork_notice,
|
||||
notice_now: edit_in_new_fork_notice_now
|
||||
}
|
||||
namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
|
||||
end
|
||||
|
||||
def edit_blob_link(project = @project, ref = @ref, path = @path, options = {})
|
||||
blob = options.delete(:blob)
|
||||
blob ||= project.repository.blob_at(ref, path) rescue nil
|
||||
|
||||
return unless blob
|
||||
|
||||
edit_path = namespace_project_edit_blob_path(project.namespace, project,
|
||||
tree_join(ref, path),
|
||||
options[:link_opts])
|
||||
common_classes = "btn js-edit-blob #{options[:extra_class]}"
|
||||
|
||||
if !on_top_of_branch?(project, ref)
|
||||
button_tag "Edit", class: "btn disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
|
||||
elsif can_edit_blob?(blob, project, ref)
|
||||
link_to "Edit", edit_path, class: 'btn btn-sm'
|
||||
elsif can?(current_user, :fork_project, project)
|
||||
continue_params = {
|
||||
to: edit_path,
|
||||
notice: edit_in_new_fork_notice,
|
||||
notice_now: edit_in_new_fork_notice_now
|
||||
}
|
||||
fork_path = namespace_project_forks_path(project.namespace, project, namespace_key: current_user.namespace.id, continue: continue_params)
|
||||
|
||||
link_to "Edit", fork_path, class: 'btn', method: :post
|
||||
button_tag 'Edit', class: "#{common_classes} disabled has-tooltip", title: "You can only edit files when you are on a branch", data: { container: 'body' }
|
||||
# This condition applies to anonymous or users who can edit directly
|
||||
elsif !current_user || (current_user && can_edit_blob?(blob, project, ref))
|
||||
link_to 'Edit', edit_path(project, ref, path, options), class: "#{common_classes} btn-sm"
|
||||
elsif current_user && can?(current_user, :fork_project, project)
|
||||
button_tag 'Edit', class: "#{common_classes} js-edit-blob-link-fork-toggler"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
module DropdownsHelper
|
||||
def dropdown_tag(toggle_text, options: {}, &block)
|
||||
content_tag :div, class: "dropdown" do
|
||||
content_tag :div, class: "dropdown #{options[:wrapper_class] if options.has_key?(:wrapper_class)}" do
|
||||
data_attr = { toggle: "dropdown" }
|
||||
|
||||
if options.has_key?(:data)
|
||||
|
@ -20,7 +20,7 @@ module DropdownsHelper
|
|||
output << dropdown_filter(options[:placeholder])
|
||||
end
|
||||
|
||||
output << content_tag(:div, class: "dropdown-content") do
|
||||
output << content_tag(:div, class: "dropdown-content #{options[:content_class] if options.has_key?(:content_class)}") do
|
||||
capture(&block) if block && !options.has_key?(:footer_content)
|
||||
end
|
||||
|
||||
|
|
|
@ -3,13 +3,14 @@ class AwardEmoji < ActiveRecord::Base
|
|||
UPVOTE_NAME = "thumbsup".freeze
|
||||
|
||||
include Participable
|
||||
include GhostUser
|
||||
|
||||
belongs_to :awardable, polymorphic: true
|
||||
belongs_to :user
|
||||
|
||||
validates :awardable, :user, presence: true
|
||||
validates :name, presence: true, inclusion: { in: Gitlab::Emoji.emojis_names }
|
||||
validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }
|
||||
validates :name, uniqueness: { scope: [:user, :awardable_type, :awardable_id] }, unless: :ghost_user?
|
||||
|
||||
participant :user
|
||||
|
||||
|
|
|
@ -171,19 +171,6 @@ module Ci
|
|||
latest_builds.where('stage_idx < ?', stage_idx)
|
||||
end
|
||||
|
||||
def trace_html(**args)
|
||||
trace_with_state(**args)[:html] || ''
|
||||
end
|
||||
|
||||
def trace_with_state(state: nil, last_lines: nil)
|
||||
trace_ansi = trace(last_lines: last_lines)
|
||||
if trace_ansi.present?
|
||||
Ci::Ansi2html.convert(trace_ansi, state)
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def timeout
|
||||
project.build_timeout
|
||||
end
|
||||
|
@ -244,136 +231,35 @@ module Ci
|
|||
end
|
||||
|
||||
def update_coverage
|
||||
coverage = extract_coverage(trace, coverage_regex)
|
||||
coverage = trace.extract_coverage(coverage_regex)
|
||||
update_attributes(coverage: coverage) if coverage.present?
|
||||
end
|
||||
|
||||
def extract_coverage(text, regex)
|
||||
return unless regex
|
||||
|
||||
matches = text.scan(Regexp.new(regex)).last
|
||||
matches = matches.last if matches.is_a?(Array)
|
||||
coverage = matches.gsub(/\d+(\.\d+)?/).first
|
||||
|
||||
if coverage.present?
|
||||
coverage.to_f
|
||||
end
|
||||
rescue
|
||||
# if bad regex or something goes wrong we dont want to interrupt transition
|
||||
# so we just silentrly ignore error for now
|
||||
end
|
||||
|
||||
def has_trace_file?
|
||||
File.exist?(path_to_trace) || has_old_trace_file?
|
||||
def trace
|
||||
Gitlab::Ci::Trace.new(self)
|
||||
end
|
||||
|
||||
def has_trace?
|
||||
raw_trace.present?
|
||||
trace.exist?
|
||||
end
|
||||
|
||||
def raw_trace(last_lines: nil)
|
||||
if File.exist?(trace_file_path)
|
||||
Gitlab::Ci::TraceReader.new(trace_file_path).
|
||||
read(last_lines: last_lines)
|
||||
else
|
||||
# backward compatibility
|
||||
read_attribute :trace
|
||||
end
|
||||
def trace=(data)
|
||||
raise NotImplementedError
|
||||
end
|
||||
|
||||
##
|
||||
# Deprecated
|
||||
#
|
||||
# This is a hotfix for CI build data integrity, see #4246
|
||||
def has_old_trace_file?
|
||||
project.ci_id && File.exist?(old_path_to_trace)
|
||||
def old_trace
|
||||
read_attribute(:trace)
|
||||
end
|
||||
|
||||
def trace(last_lines: nil)
|
||||
hide_secrets(raw_trace(last_lines: last_lines))
|
||||
end
|
||||
|
||||
def trace_length
|
||||
if raw_trace
|
||||
raw_trace.bytesize
|
||||
else
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
def trace=(trace)
|
||||
recreate_trace_dir
|
||||
trace = hide_secrets(trace)
|
||||
File.write(path_to_trace, trace)
|
||||
end
|
||||
|
||||
def recreate_trace_dir
|
||||
unless Dir.exist?(dir_to_trace)
|
||||
FileUtils.mkdir_p(dir_to_trace)
|
||||
end
|
||||
end
|
||||
private :recreate_trace_dir
|
||||
|
||||
def append_trace(trace_part, offset)
|
||||
recreate_trace_dir
|
||||
touch if needs_touch?
|
||||
|
||||
trace_part = hide_secrets(trace_part)
|
||||
|
||||
File.truncate(path_to_trace, offset) if File.exist?(path_to_trace)
|
||||
File.open(path_to_trace, 'ab') do |f|
|
||||
f.write(trace_part)
|
||||
end
|
||||
def erase_old_trace!
|
||||
write_attribute(:trace, nil)
|
||||
save
|
||||
end
|
||||
|
||||
def needs_touch?
|
||||
Time.now - updated_at > 15.minutes.to_i
|
||||
end
|
||||
|
||||
def trace_file_path
|
||||
if has_old_trace_file?
|
||||
old_path_to_trace
|
||||
else
|
||||
path_to_trace
|
||||
end
|
||||
end
|
||||
|
||||
def dir_to_trace
|
||||
File.join(
|
||||
Settings.gitlab_ci.builds_path,
|
||||
created_at.utc.strftime("%Y_%m"),
|
||||
project.id.to_s
|
||||
)
|
||||
end
|
||||
|
||||
def path_to_trace
|
||||
"#{dir_to_trace}/#{id}.log"
|
||||
end
|
||||
|
||||
##
|
||||
# Deprecated
|
||||
#
|
||||
# This is a hotfix for CI build data integrity, see #4246
|
||||
# Should be removed in 8.4, after CI files migration has been done.
|
||||
#
|
||||
def old_dir_to_trace
|
||||
File.join(
|
||||
Settings.gitlab_ci.builds_path,
|
||||
created_at.utc.strftime("%Y_%m"),
|
||||
project.ci_id.to_s
|
||||
)
|
||||
end
|
||||
|
||||
##
|
||||
# Deprecated
|
||||
#
|
||||
# This is a hotfix for CI build data integrity, see #4246
|
||||
# Should be removed in 8.4, after CI files migration has been done.
|
||||
#
|
||||
def old_path_to_trace
|
||||
"#{old_dir_to_trace}/#{id}.log"
|
||||
end
|
||||
|
||||
##
|
||||
# Deprecated
|
||||
#
|
||||
|
@ -555,6 +441,15 @@ module Ci
|
|||
options[:dependencies]&.empty?
|
||||
end
|
||||
|
||||
def hide_secrets(trace)
|
||||
return unless trace
|
||||
|
||||
trace = trace.dup
|
||||
Ci::MaskSecret.mask!(trace, project.runners_token) if project
|
||||
Ci::MaskSecret.mask!(trace, token)
|
||||
trace
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_artifacts_size
|
||||
|
@ -566,7 +461,7 @@ module Ci
|
|||
end
|
||||
|
||||
def erase_trace!
|
||||
self.trace = nil
|
||||
trace.erase!
|
||||
end
|
||||
|
||||
def update_erased!(user = nil)
|
||||
|
@ -628,15 +523,6 @@ module Ci
|
|||
pipeline.config_processor.build_attributes(name)
|
||||
end
|
||||
|
||||
def hide_secrets(trace)
|
||||
return unless trace
|
||||
|
||||
trace = trace.dup
|
||||
Ci::MaskSecret.mask!(trace, project.runners_token) if project
|
||||
Ci::MaskSecret.mask!(trace, token)
|
||||
trace
|
||||
end
|
||||
|
||||
def update_project_statistics
|
||||
return unless project
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ module Ci
|
|||
belongs_to :owner, class_name: "User"
|
||||
|
||||
has_many :trigger_requests, dependent: :destroy
|
||||
has_one :trigger_schedule, dependent: :destroy
|
||||
|
||||
validates :token, presence: true, uniqueness: true
|
||||
|
||||
|
|
30
app/models/ci/trigger_schedule.rb
Normal file
|
@ -0,0 +1,30 @@
|
|||
module Ci
|
||||
class TriggerSchedule < ActiveRecord::Base
|
||||
extend Ci::Model
|
||||
include Importable
|
||||
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :project
|
||||
belongs_to :trigger
|
||||
|
||||
delegate :ref, to: :trigger
|
||||
|
||||
validates :trigger, presence: { unless: :importing? }
|
||||
validates :cron, cron: true, presence: { unless: :importing? }
|
||||
validates :cron_timezone, cron_timezone: true, presence: { unless: :importing? }
|
||||
validates :ref, presence: { unless: :importing? }
|
||||
|
||||
before_save :set_next_run_at
|
||||
|
||||
def set_next_run_at
|
||||
self.next_run_at = Gitlab::Ci::CronParser.new(cron, cron_timezone).next_time_from(Time.now)
|
||||
end
|
||||
|
||||
def schedule_next_run!
|
||||
save! # with set_next_run_at
|
||||
rescue ActiveRecord::RecordInvalid
|
||||
update_attribute(:next_run_at, nil) # update without validation
|
||||
end
|
||||
end
|
||||
end
|
7
app/models/concerns/ghost_user.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
module GhostUser
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def ghost_user?
|
||||
user && user.ghost?
|
||||
end
|
||||
end
|
|
@ -83,6 +83,74 @@ module Routable
|
|||
AND members.source_type = r2.source_type").
|
||||
where('members.user_id = ?', user_id)
|
||||
end
|
||||
|
||||
# Builds a relation to find multiple objects that are nested under user
|
||||
# membership. Includes the parent, as opposed to `#member_descendants`
|
||||
# which only includes the descendants.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# Klass.member_self_and_descendants(1)
|
||||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def member_self_and_descendants(user_id)
|
||||
joins(:route).
|
||||
joins("INNER JOIN routes r2 ON routes.path LIKE CONCAT(r2.path, '/%')
|
||||
OR routes.path = r2.path
|
||||
INNER JOIN members ON members.source_id = r2.source_id
|
||||
AND members.source_type = r2.source_type").
|
||||
where('members.user_id = ?', user_id)
|
||||
end
|
||||
|
||||
# Returns all objects in a hierarchy, where any node in the hierarchy is
|
||||
# under the user membership.
|
||||
#
|
||||
# Usage:
|
||||
#
|
||||
# Klass.member_hierarchy(1)
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# Given the following group tree...
|
||||
#
|
||||
# _______group_1_______
|
||||
# | |
|
||||
# | |
|
||||
# nested_group_1 nested_group_2
|
||||
# | |
|
||||
# | |
|
||||
# nested_group_1_1 nested_group_2_1
|
||||
#
|
||||
#
|
||||
# ... the following results are returned:
|
||||
#
|
||||
# * the user is a member of group 1
|
||||
# => 'group_1',
|
||||
# 'nested_group_1', nested_group_1_1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# * the user is a member of nested_group_2
|
||||
# => 'group1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# * the user is a member of nested_group_2_1
|
||||
# => 'group1',
|
||||
# 'nested_group_2', 'nested_group_2_1'
|
||||
#
|
||||
# Returns an ActiveRecord::Relation.
|
||||
def member_hierarchy(user_id)
|
||||
paths = member_self_and_descendants(user_id).pluck('routes.path')
|
||||
|
||||
return none if paths.empty?
|
||||
|
||||
wheres = paths.map do |path|
|
||||
"#{connection.quote(path)} = routes.path
|
||||
OR
|
||||
#{connection.quote(path)} LIKE CONCAT(routes.path, '/%')"
|
||||
end
|
||||
|
||||
joins(:route).where(wheres.join(' OR '))
|
||||
end
|
||||
end
|
||||
|
||||
def full_name
|
||||
|
|
77
app/models/container_repository.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
class ContainerRepository < ActiveRecord::Base
|
||||
belongs_to :project
|
||||
|
||||
validates :name, length: { minimum: 0, allow_nil: false }
|
||||
validates :name, uniqueness: { scope: :project_id }
|
||||
|
||||
delegate :client, to: :registry
|
||||
|
||||
before_destroy :delete_tags!
|
||||
|
||||
def registry
|
||||
@registry ||= begin
|
||||
token = Auth::ContainerRegistryAuthenticationService.full_access_token(path)
|
||||
|
||||
url = Gitlab.config.registry.api_url
|
||||
host_port = Gitlab.config.registry.host_port
|
||||
|
||||
ContainerRegistry::Registry.new(url, token: token, path: host_port)
|
||||
end
|
||||
end
|
||||
|
||||
def path
|
||||
@path ||= [project.full_path, name].select(&:present?).join('/')
|
||||
end
|
||||
|
||||
def tag(tag)
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
|
||||
def manifest
|
||||
@manifest ||= client.repository_tags(path)
|
||||
end
|
||||
|
||||
def tags
|
||||
return @tags if defined?(@tags)
|
||||
return [] unless manifest && manifest['tags']
|
||||
|
||||
@tags = manifest['tags'].map do |tag|
|
||||
ContainerRegistry::Tag.new(self, tag)
|
||||
end
|
||||
end
|
||||
|
||||
def blob(config)
|
||||
ContainerRegistry::Blob.new(self, config)
|
||||
end
|
||||
|
||||
def has_tags?
|
||||
tags.any?
|
||||
end
|
||||
|
||||
def root_repository?
|
||||
name.empty?
|
||||
end
|
||||
|
||||
def delete_tags!
|
||||
return unless has_tags?
|
||||
|
||||
digests = tags.map { |tag| tag.digest }.to_set
|
||||
|
||||
digests.all? do |digest|
|
||||
client.delete_repository_tag(self.path, digest)
|
||||
end
|
||||
end
|
||||
|
||||
def self.build_from_path(path)
|
||||
self.new(project: path.repository_project,
|
||||
name: path.repository_name)
|
||||
end
|
||||
|
||||
def self.create_from_path!(path)
|
||||
build_from_path(path).tap(&:save!)
|
||||
end
|
||||
|
||||
def self.build_root_repository(project)
|
||||
self.new(project: project, name: '')
|
||||
end
|
||||
end
|
|
@ -27,11 +27,14 @@ class Group < Namespace
|
|||
|
||||
validates :avatar, file_size: { maximum: 200.kilobytes.to_i }
|
||||
|
||||
validates :two_factor_grace_period, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
mount_uploader :avatar, AvatarUploader
|
||||
has_many :uploads, as: :model, dependent: :destroy
|
||||
|
||||
after_create :post_create_hook
|
||||
after_destroy :post_destroy_hook
|
||||
after_save :update_two_factor_requirement
|
||||
|
||||
class << self
|
||||
# Searches for groups matching the given query.
|
||||
|
@ -223,4 +226,12 @@ class Group < Namespace
|
|||
type: public? ? 'O' : 'I' # Open vs Invite-only
|
||||
}
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def update_two_factor_requirement
|
||||
return unless require_two_factor_authentication_changed? || two_factor_grace_period_changed?
|
||||
|
||||
users.find_each(&:update_two_factor_requirement)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -3,11 +3,16 @@ class GroupMember < Member
|
|||
|
||||
belongs_to :group, foreign_key: 'source_id'
|
||||
|
||||
delegate :update_two_factor_requirement, to: :user
|
||||
|
||||
# Make sure group member points only to group as it source
|
||||
default_value_for :source_type, SOURCE_TYPE
|
||||
validates :source_type, format: { with: /\ANamespace\z/ }
|
||||
default_scope { where(source_type: SOURCE_TYPE) }
|
||||
|
||||
after_create :update_two_factor_requirement, unless: :invite?
|
||||
after_destroy :update_two_factor_requirement, unless: :invite?
|
||||
|
||||
def self.access_level_roles
|
||||
Gitlab::Access.options_with_owner
|
||||
end
|
||||
|
|
|
@ -159,6 +159,7 @@ class Project < ActiveRecord::Base
|
|||
has_one :import_data, dependent: :destroy, class_name: "ProjectImportData"
|
||||
has_one :project_feature, dependent: :destroy
|
||||
has_one :statistics, class_name: 'ProjectStatistics', dependent: :delete
|
||||
has_many :container_repositories, dependent: :destroy
|
||||
|
||||
has_many :commit_statuses, dependent: :destroy
|
||||
has_many :pipelines, dependent: :destroy, class_name: 'Ci::Pipeline'
|
||||
|
@ -408,32 +409,15 @@ class Project < ActiveRecord::Base
|
|||
@repository ||= Repository.new(path_with_namespace, self)
|
||||
end
|
||||
|
||||
def container_registry_path_with_namespace
|
||||
path_with_namespace.downcase
|
||||
end
|
||||
|
||||
def container_registry_repository
|
||||
return unless Gitlab.config.registry.enabled
|
||||
|
||||
@container_registry_repository ||= begin
|
||||
token = Auth::ContainerRegistryAuthenticationService.full_access_token(container_registry_path_with_namespace)
|
||||
url = Gitlab.config.registry.api_url
|
||||
host_port = Gitlab.config.registry.host_port
|
||||
registry = ContainerRegistry::Registry.new(url, token: token, path: host_port)
|
||||
registry.repository(container_registry_path_with_namespace)
|
||||
end
|
||||
end
|
||||
|
||||
def container_registry_repository_url
|
||||
def container_registry_url
|
||||
if Gitlab.config.registry.enabled
|
||||
"#{Gitlab.config.registry.host_port}/#{container_registry_path_with_namespace}"
|
||||
"#{Gitlab.config.registry.host_port}/#{path_with_namespace.downcase}"
|
||||
end
|
||||
end
|
||||
|
||||
def has_container_registry_tags?
|
||||
return unless container_registry_repository
|
||||
|
||||
container_registry_repository.tags.any?
|
||||
container_repositories.to_a.any?(&:has_tags?) ||
|
||||
has_root_container_repository_tags?
|
||||
end
|
||||
|
||||
def commit(ref = 'HEAD')
|
||||
|
@ -924,10 +908,10 @@ class Project < ActiveRecord::Base
|
|||
expire_caches_before_rename(old_path_with_namespace)
|
||||
|
||||
if has_container_registry_tags?
|
||||
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present"
|
||||
Rails.logger.error "Project #{old_path_with_namespace} cannot be renamed because container registry tags are present!"
|
||||
|
||||
# we currently doesn't support renaming repository if it contains tags in container registry
|
||||
raise StandardError.new('Project cannot be renamed, because tags are present in its container registry')
|
||||
# we currently doesn't support renaming repository if it contains images in container registry
|
||||
raise StandardError.new('Project cannot be renamed, because images are present in its container registry')
|
||||
end
|
||||
|
||||
if gitlab_shell.mv_repository(repository_storage_path, old_path_with_namespace, new_path_with_namespace)
|
||||
|
@ -1117,10 +1101,6 @@ class Project < ActiveRecord::Base
|
|||
self.runners_token && ActiveSupport::SecurityUtils.variable_size_secure_compare(token, self.runners_token)
|
||||
end
|
||||
|
||||
def build_coverage_enabled?
|
||||
build_coverage_regex.present?
|
||||
end
|
||||
|
||||
def build_timeout_in_minutes
|
||||
build_timeout / 60
|
||||
end
|
||||
|
@ -1274,7 +1254,7 @@ class Project < ActiveRecord::Base
|
|||
]
|
||||
|
||||
if container_registry_enabled?
|
||||
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_repository_url, public: true }
|
||||
variables << { key: 'CI_REGISTRY_IMAGE', value: container_registry_url, public: true }
|
||||
end
|
||||
|
||||
variables
|
||||
|
@ -1407,4 +1387,15 @@ class Project < ActiveRecord::Base
|
|||
|
||||
Project.unscoped.where(pending_delete: true).find_by_full_path(path_with_namespace)
|
||||
end
|
||||
|
||||
##
|
||||
# This method is here because of support for legacy container repository
|
||||
# which has exactly the same path like project does, but which might not be
|
||||
# persisted in `container_repositories` table.
|
||||
#
|
||||
def has_root_container_repository_tags?
|
||||
return false unless Gitlab.config.registry.enabled
|
||||
|
||||
ContainerRepository.build_root_repository(self).has_tags?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,6 +6,8 @@ class Repository
|
|||
|
||||
attr_accessor :path_with_namespace, :project
|
||||
|
||||
delegate :ref_name_for_sha, to: :raw_repository
|
||||
|
||||
CommitError = Class.new(StandardError)
|
||||
CreateTreeError = Class.new(StandardError)
|
||||
|
||||
|
@ -700,14 +702,6 @@ class Repository
|
|||
end
|
||||
end
|
||||
|
||||
def ref_name_for_sha(ref_path, sha)
|
||||
args = %W(#{Gitlab.config.git.bin_path} for-each-ref --count=1 #{ref_path} --contains #{sha})
|
||||
|
||||
# Not found -> ["", 0]
|
||||
# Found -> ["b8d95eb4969eefacb0a58f6a28f6803f8070e7b9 commit\trefs/environments/production/77\n", 0]
|
||||
Gitlab::Popen.popen(args, path_to_repo).first.split.last
|
||||
end
|
||||
|
||||
def refs_contains_sha(ref_type, sha)
|
||||
args = %W(#{Gitlab.config.git.bin_path} #{ref_type} --contains #{sha})
|
||||
names = Gitlab::Popen.popen(args, path_to_repo).first
|
||||
|
|
|
@ -89,7 +89,8 @@ class User < ActiveRecord::Base
|
|||
has_many :subscriptions, dependent: :destroy
|
||||
has_many :recent_events, -> { order "id DESC" }, foreign_key: :author_id, class_name: "Event"
|
||||
has_many :oauth_applications, class_name: 'Doorkeeper::Application', as: :owner, dependent: :destroy
|
||||
has_one :abuse_report, dependent: :destroy
|
||||
has_one :abuse_report, dependent: :destroy, foreign_key: :user_id
|
||||
has_many :reported_abuse_reports, dependent: :destroy, foreign_key: :reporter_id, class_name: "AbuseReport"
|
||||
has_many :spam_logs, dependent: :destroy
|
||||
has_many :builds, dependent: :nullify, class_name: 'Ci::Build'
|
||||
has_many :pipelines, dependent: :nullify, class_name: 'Ci::Pipeline'
|
||||
|
@ -484,6 +485,14 @@ class User < ActiveRecord::Base
|
|||
Group.member_descendants(id)
|
||||
end
|
||||
|
||||
def all_expanded_groups
|
||||
Group.member_hierarchy(id)
|
||||
end
|
||||
|
||||
def expanded_groups_requiring_two_factor_authentication
|
||||
all_expanded_groups.where(require_two_factor_authentication: true)
|
||||
end
|
||||
|
||||
def nested_groups_projects
|
||||
Project.joins(:namespace).where('namespaces.parent_id IS NOT NULL').
|
||||
member_descendants(id)
|
||||
|
@ -955,6 +964,15 @@ class User < ActiveRecord::Base
|
|||
self.admin = (new_level == 'admin')
|
||||
end
|
||||
|
||||
def update_two_factor_requirement
|
||||
periods = expanded_groups_requiring_two_factor_authentication.pluck(:two_factor_grace_period)
|
||||
|
||||
self.require_two_factor_authentication_from_group = periods.any?
|
||||
self.two_factor_grace_period = periods.min || User.column_defaults['two_factor_grace_period']
|
||||
|
||||
save
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# override, from Devise::Validatable
|
||||
|
|
|
@ -17,6 +17,7 @@ module Auth
|
|||
end
|
||||
|
||||
def self.full_access_token(*names)
|
||||
names = names.flatten
|
||||
registry = Gitlab.config.registry
|
||||
token = JSONWebToken::RSAToken.new(registry.key)
|
||||
token.issuer = registry.issuer
|
||||
|
@ -37,13 +38,13 @@ module Auth
|
|||
private
|
||||
|
||||
def authorized_token(*accesses)
|
||||
token = JSONWebToken::RSAToken.new(registry.key)
|
||||
token.issuer = registry.issuer
|
||||
token.audience = params[:service]
|
||||
token.subject = current_user.try(:username)
|
||||
token.expire_time = self.class.token_expire_at
|
||||
token[:access] = accesses.compact
|
||||
token
|
||||
JSONWebToken::RSAToken.new(registry.key).tap do |token|
|
||||
token.issuer = registry.issuer
|
||||
token.audience = params[:service]
|
||||
token.subject = current_user.try(:username)
|
||||
token.expire_time = self.class.token_expire_at
|
||||
token[:access] = accesses.compact
|
||||
end
|
||||
end
|
||||
|
||||
def scope
|
||||
|
@ -55,20 +56,43 @@ module Auth
|
|||
def process_scope(scope)
|
||||
type, name, actions = scope.split(':', 3)
|
||||
actions = actions.split(',')
|
||||
path = ContainerRegistry::Path.new(name)
|
||||
|
||||
return unless type == 'repository'
|
||||
|
||||
process_repository_access(type, name, actions)
|
||||
process_repository_access(type, path, actions)
|
||||
end
|
||||
|
||||
def process_repository_access(type, name, actions)
|
||||
requested_project = Project.find_by_full_path(name)
|
||||
def process_repository_access(type, path, actions)
|
||||
return unless path.valid?
|
||||
|
||||
requested_project = path.repository_project
|
||||
|
||||
return unless requested_project
|
||||
|
||||
actions = actions.select do |action|
|
||||
can_access?(requested_project, action)
|
||||
end
|
||||
|
||||
{ type: type, name: name, actions: actions } if actions.present?
|
||||
return unless actions.present?
|
||||
|
||||
# At this point user/build is already authenticated.
|
||||
#
|
||||
ensure_container_repository!(path, actions)
|
||||
|
||||
{ type: type, name: path.to_s, actions: actions }
|
||||
end
|
||||
|
||||
##
|
||||
# Because we do not have two way communication with registry yet,
|
||||
# we create a container repository image resource when push to the
|
||||
# registry is successfuly authorized.
|
||||
#
|
||||
def ensure_container_repository!(path, actions)
|
||||
return if path.has_repository?
|
||||
return unless actions.include?('push')
|
||||
|
||||
ContainerRepository.create_from_path!(path)
|
||||
end
|
||||
|
||||
def can_access?(requested_project, requested_action)
|
||||
|
@ -101,6 +125,11 @@ module Auth
|
|||
can?(current_user, :read_container_image, requested_project)
|
||||
end
|
||||
|
||||
##
|
||||
# We still support legacy pipeline triggers which do not have associated
|
||||
# actor. New permissions model and new triggers are always associated with
|
||||
# an actor, so this should be improved in 10.0 version of GitLab.
|
||||
#
|
||||
def build_can_push?(requested_project)
|
||||
# Build can push only to the project from which it originates
|
||||
has_authentication_ability?(:build_create_container_image) &&
|
||||
|
@ -113,14 +142,11 @@ module Auth
|
|||
end
|
||||
|
||||
def error(code, status:, message: '')
|
||||
{
|
||||
errors: [{ code: code, message: message }],
|
||||
http_status: status
|
||||
}
|
||||
{ errors: [{ code: code, message: message }], http_status: status }
|
||||
end
|
||||
|
||||
def has_authentication_ability?(capability)
|
||||
(@authentication_abilities || []).include?(capability)
|
||||
@authentication_abilities.to_a.include?(capability)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -31,16 +31,16 @@ module Projects
|
|||
project.team.truncate
|
||||
project.destroy!
|
||||
|
||||
unless remove_registry_tags
|
||||
raise_error('Failed to remove project container registry. Please try again or contact administrator')
|
||||
unless remove_legacy_registry_tags
|
||||
raise_error('Failed to remove some tags in project container registry. Please try again or contact administrator.')
|
||||
end
|
||||
|
||||
unless remove_repository(repo_path)
|
||||
raise_error('Failed to remove project repository. Please try again or contact administrator')
|
||||
raise_error('Failed to remove project repository. Please try again or contact administrator.')
|
||||
end
|
||||
|
||||
unless remove_repository(wiki_path)
|
||||
raise_error('Failed to remove wiki repository. Please try again or contact administrator')
|
||||
raise_error('Failed to remove wiki repository. Please try again or contact administrator.')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -68,10 +68,16 @@ module Projects
|
|||
end
|
||||
end
|
||||
|
||||
def remove_registry_tags
|
||||
##
|
||||
# This method makes sure that we correctly remove registry tags
|
||||
# for legacy image repository (when repository path equals project path).
|
||||
#
|
||||
def remove_legacy_registry_tags
|
||||
return true unless Gitlab.config.registry.enabled
|
||||
|
||||
project.container_registry_repository.delete_tags
|
||||
ContainerRepository.build_root_repository(project).tap do |repository|
|
||||
return repository.has_tags? ? repository.delete_tags! : true
|
||||
end
|
||||
end
|
||||
|
||||
def raise_error(message)
|
||||
|
|
|
@ -26,7 +26,7 @@ module Users
|
|||
::Projects::DestroyService.new(project, current_user, skip_repo: true).execute
|
||||
end
|
||||
|
||||
move_issues_to_ghost_user(user)
|
||||
MigrateToGhostUserService.new(user).execute
|
||||
|
||||
# Destroy the namespace after destroying the user since certain methods may depend on the namespace existing
|
||||
namespace = user.namespace
|
||||
|
@ -35,22 +35,5 @@ module Users
|
|||
|
||||
user_data
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def move_issues_to_ghost_user(user)
|
||||
# Block the user before moving issues to prevent a data race.
|
||||
# If the user creates an issue after `move_issues_to_ghost_user`
|
||||
# runs and before the user is destroyed, the destroy will fail with
|
||||
# an exception. We block the user so that issues can't be created
|
||||
# after `move_issues_to_ghost_user` runs and before the destroy happens.
|
||||
user.block
|
||||
|
||||
ghost_user = User.ghost
|
||||
|
||||
user.issues.update_all(author_id: ghost_user.id)
|
||||
|
||||
user.reload
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
59
app/services/users/migrate_to_ghost_user_service.rb
Normal file
|
@ -0,0 +1,59 @@
|
|||
# When a user is destroyed, some of their associated records are
|
||||
# moved to a "Ghost User", to prevent these associated records from
|
||||
# being destroyed.
|
||||
#
|
||||
# For example, all the issues/MRs a user has created are _not_ destroyed
|
||||
# when the user is destroyed.
|
||||
module Users
|
||||
class MigrateToGhostUserService
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
attr_reader :ghost_user, :user
|
||||
|
||||
def initialize(user)
|
||||
@user = user
|
||||
end
|
||||
|
||||
def execute
|
||||
# Block the user before moving records to prevent a data race.
|
||||
# For example, if the user creates an issue after `migrate_issues`
|
||||
# runs and before the user is destroyed, the destroy will fail with
|
||||
# an exception.
|
||||
user.block
|
||||
|
||||
user.transaction do
|
||||
@ghost_user = User.ghost
|
||||
|
||||
migrate_issues
|
||||
migrate_merge_requests
|
||||
migrate_notes
|
||||
migrate_abuse_reports
|
||||
migrate_award_emoji
|
||||
end
|
||||
|
||||
user.reload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def migrate_issues
|
||||
user.issues.update_all(author_id: ghost_user.id)
|
||||
end
|
||||
|
||||
def migrate_merge_requests
|
||||
user.merge_requests.update_all(author_id: ghost_user.id)
|
||||
end
|
||||
|
||||
def migrate_notes
|
||||
user.notes.update_all(author_id: ghost_user.id)
|
||||
end
|
||||
|
||||
def migrate_abuse_reports
|
||||
user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
|
||||
end
|
||||
|
||||
def migrate_award_emoji
|
||||
user.award_emoji.update_all(user_id: ghost_user.id)
|
||||
end
|
||||
end
|
||||
end
|
9
app/validators/cron_timezone_validator.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# CronTimezoneValidator
|
||||
#
|
||||
# Custom validator for CronTimezone.
|
||||
class CronTimezoneValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
|
||||
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_timezone_valid?
|
||||
end
|
||||
end
|
9
app/validators/cron_validator.rb
Normal file
|
@ -0,0 +1,9 @@
|
|||
# CronValidator
|
||||
#
|
||||
# Custom validator for Cron.
|
||||
class CronValidator < ActiveModel::EachValidator
|
||||
def validate_each(record, attribute, value)
|
||||
cron_parser = Gitlab::Ci::CronParser.new(record.cron, record.cron_timezone)
|
||||
record.errors.add(attribute, " is invalid syntax") unless cron_parser.cron_valid?
|
||||
end
|
||||
end
|
|
@ -13,7 +13,7 @@
|
|||
.col-sm-offset-2.col-sm-10
|
||||
= render 'shared/allow_request_access', form: f
|
||||
|
||||
= render 'groups/group_lfs_settings', f: f
|
||||
= render 'groups/group_admin_settings', f: f
|
||||
|
||||
- if @group.new_record?
|
||||
.form-group
|
||||
|
|
|
@ -13,5 +13,7 @@
|
|||
%button.btn.award-control.has-tooltip.js-add-award{ type: 'button',
|
||||
'aria-label': 'Add emoji',
|
||||
data: { title: 'Add emoji', placement: "bottom" } }
|
||||
= icon('smile-o', class: "award-control-icon award-control-icon-normal")
|
||||
%span{ class: "award-control-icon award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
|
||||
%span{ class: "award-control-icon award-control-icon-positive" }= custom_icon('emoji_smiley')
|
||||
%span{ class: "award-control-icon award-control-icon-super-positive" }= custom_icon('emoji_smile')
|
||||
= icon('spinner spin', class: "award-control-icon award-control-icon-loading")
|
||||
|
|
|
@ -3,8 +3,6 @@
|
|||
.event-item-timestamp
|
||||
#{time_ago_with_tooltip(event.created_at)}
|
||||
|
||||
= author_avatar(event, size: 40)
|
||||
|
||||
- if event.created_project?
|
||||
= render "events/event/created_project", event: event
|
||||
- elsif event.push?
|
||||
|
|
|
@ -1,5 +1,15 @@
|
|||
- if event.target
|
||||
- if event.action_name == "opened"
|
||||
.profile-icon.open-icon
|
||||
= custom_icon("icon_status_open")
|
||||
- elsif event.action_name == "closed"
|
||||
.profile-icon.closed-icon
|
||||
= custom_icon("icon_status_closed")
|
||||
- else
|
||||
.profile-icon.fork-icon
|
||||
= custom_icon("code_fork")
|
||||
|
||||
.event-title
|
||||
%span.author_name= link_to_author event
|
||||
%span{ class: event.action_name }
|
||||
- if event.target
|
||||
= event.action_name
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
.profile-icon.open-icon
|
||||
= custom_icon("icon_status_open")
|
||||
|
||||
.event-title
|
||||
%span.author_name= link_to_author event
|
||||
%span{ class: event.action_name }
|
||||
= event_action_name(event)
|
||||
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
.profile-icon
|
||||
= custom_icon("comment_o")
|
||||
|
||||
.event-title
|
||||
%span.author_name= link_to_author event
|
||||
= event.action_name
|
||||
= event_note_title_html(event)
|
||||
|
||||
|
|
|
@ -1,7 +1,12 @@
|
|||
- project = event.project
|
||||
|
||||
.profile-icon
|
||||
- if event.action_name == "deleted"
|
||||
= custom_icon("trash_o")
|
||||
- else
|
||||
= custom_icon("icon_commit")
|
||||
|
||||
.event-title
|
||||
%span.author_name= link_to_author event
|
||||
%span.pushed #{event.action_name} #{event.ref_type}
|
||||
%strong
|
||||
- commits_link = namespace_project_commits_path(project.namespace, project, event.ref_name)
|
||||
|
@ -48,4 +53,3 @@
|
|||
.event-body
|
||||
%ul.well-list.event_commits
|
||||
= render "events/commit", commit: last_commit, project: project, event: event
|
||||
|
||||
|
|
28
app/views/groups/_group_admin_settings.html.haml
Normal file
|
@ -0,0 +1,28 @@
|
|||
- if current_user.admin?
|
||||
.form-group
|
||||
= f.label :lfs_enabled, 'Large File Storage', class: 'control-label'
|
||||
.col-sm-10
|
||||
.checkbox
|
||||
= f.label :lfs_enabled do
|
||||
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?
|
||||
%strong
|
||||
Allow projects within this group to use Git LFS
|
||||
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
|
||||
%br/
|
||||
%span.descr This setting can be overridden in each project.
|
||||
|
||||
- if can? current_user, :admin_group, @group
|
||||
.form-group
|
||||
= f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
.checkbox
|
||||
= f.label :require_two_factor_authentication do
|
||||
= f.check_box :require_two_factor_authentication
|
||||
%strong
|
||||
Require all users in this group to setup Two-factor authentication
|
||||
= link_to icon('question-circle'), help_page_path('security/two_factor_authentication', anchor: 'enforcing-2fa-for-all-users-in-a-group')
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
.checkbox
|
||||
= f.text_field :two_factor_grace_period, class: 'form-control'
|
||||
.help-block Amount of time (in hours) that users are allowed to skip forced configuration of two-factor authentication
|
|
@ -1,11 +0,0 @@
|
|||
- if current_user.admin?
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
.checkbox
|
||||
= f.label :lfs_enabled do
|
||||
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?
|
||||
%strong
|
||||
Allow projects within this group to use Git LFS
|
||||
= link_to icon('question-circle'), help_page_path('workflow/lfs/manage_large_binaries_with_git_lfs')
|
||||
%br/
|
||||
%span.descr This setting can be overridden in each project.
|
|
@ -27,7 +27,7 @@
|
|||
.col-sm-offset-2.col-sm-10
|
||||
= render 'shared/allow_request_access', form: f
|
||||
|
||||
= render 'group_lfs_settings', f: f
|
||||
= render 'group_admin_settings', f: f
|
||||
|
||||
.form-group
|
||||
%hr
|
||||
|
|
|
@ -137,6 +137,6 @@
|
|||
- if build.has_trace?
|
||||
%td{ colspan: "2", style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;padding:0 0 15px;" }
|
||||
%pre{ style: "font-family:Monaco,'Lucida Console','Courier New',Courier,monospace;background-color:#fafafa;border-radius:3px;overflow:hidden;white-space:pre-wrap;word-break:break-all;font-size:13px;line-height:1.4;padding:12px;color:#333333;margin:0;" }
|
||||
= build.trace_html(last_lines: 10).html_safe
|
||||
= build.trace.html(last_lines: 10).html_safe
|
||||
- else
|
||||
%td{ colspan: "2" }
|
||||
|
|
|
@ -35,7 +35,7 @@ had <%= failed.size %> failed <%= 'build'.pluralize(failed.size) %>.
|
|||
Stage: <%= build.stage %>
|
||||
Name: <%= build.name %>
|
||||
<% if build.has_trace? -%>
|
||||
Trace: <%= build.trace_with_state(last_lines: 10)[:text] %>
|
||||
Trace: <%= build.trace.raw(last_lines: 10) %>
|
||||
<% end -%>
|
||||
|
||||
<% end -%>
|
||||
|
|
|
@ -25,4 +25,11 @@
|
|||
#blob-content-holder.blob-content-holder
|
||||
%article.file-holder
|
||||
= render "projects/blob/header", blob: blob
|
||||
- if current_user
|
||||
.js-file-fork-suggestion-section.file-fork-suggestion.hidden
|
||||
%span.file-fork-suggestion-note
|
||||
You don't have permission to edit this file. Try forking this project to edit the file.
|
||||
= link_to 'Fork', fork_path, method: :post, class: 'btn btn-grouped btn-inverted btn-new'
|
||||
%button.js-cancel-fork-suggestion.btn.btn-grouped{ type: 'button' }
|
||||
Cancel
|
||||
= render blob.to_partial_path(@project), blob: blob
|
||||
|
|
|
@ -32,8 +32,8 @@
|
|||
= link_to 'Permalink', namespace_project_blob_path(@project.namespace, @project,
|
||||
tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url'
|
||||
|
||||
- if current_user
|
||||
.btn-group{ role: "group" }<
|
||||
= edit_blob_link if blob_text_viewable?(blob)
|
||||
.btn-group{ role: "group" }<
|
||||
= edit_blob_link if blob_text_viewable?(blob)
|
||||
- if current_user
|
||||
= replace_blob_link
|
||||
= delete_blob_link
|
||||
|
|
|
@ -68,7 +68,7 @@
|
|||
- elsif @build.runner
|
||||
\##{@build.runner.id}
|
||||
.btn-group.btn-group-justified{ role: :group }
|
||||
- if @build.has_trace_file?
|
||||
- if @build.has_trace?
|
||||
= link_to 'Raw', raw_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default'
|
||||
- if @build.active?
|
||||
= link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post
|
||||
|
|
|
@ -20,6 +20,6 @@
|
|||
%th Coverage
|
||||
%th
|
||||
|
||||
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, coverage: admin || project.build_coverage_enabled?, admin: admin }
|
||||
= render partial: "projects/ci/builds/build", collection: builds, as: :build, locals: { commit_sha: true, ref: true, pipeline_link: true, stage: true, allow_retry: true, admin: admin }
|
||||
|
||||
= paginate builds, theme: 'gitlab'
|
||||
|
|
|
@ -6,7 +6,6 @@
|
|||
- retried = local_assigns.fetch(:retried, false)
|
||||
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
|
||||
- stage = local_assigns.fetch(:stage, false)
|
||||
- coverage = local_assigns.fetch(:coverage, false)
|
||||
- allow_retry = local_assigns.fetch(:allow_retry, false)
|
||||
|
||||
%tr.build.commit{ class: ('retried' if retried) }
|
||||
|
@ -90,7 +89,7 @@
|
|||
%span= time_ago_with_tooltip(job.finished_at)
|
||||
|
||||
%td.coverage
|
||||
- if coverage && job.try(:coverage)
|
||||
- if job.try(:coverage)
|
||||
#{job.coverage}%
|
||||
|
||||
%td
|
||||
|
|
|
@ -47,7 +47,6 @@
|
|||
%th Job ID
|
||||
%th Name
|
||||
%th
|
||||
- if pipeline.project.build_coverage_enabled?
|
||||
%th Coverage
|
||||
%th Coverage
|
||||
%th
|
||||
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
- environment = local_assigns.fetch(:environment)
|
||||
|
||||
- return unless environment.has_metrics? && can?(current_user, :read_environment, environment)
|
||||
- return unless can?(current_user, :read_environment, environment)
|
||||
|
||||
= link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do
|
||||
= icon('area-chart')
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
= page_specific_javascript_bundle_tag('monitoring')
|
||||
= render "projects/pipelines/head"
|
||||
|
||||
%div{ class: container_class }
|
||||
.prometheus-container{ class: container_class, 'data-has-metrics': "#{@environment.has_metrics?}" }
|
||||
.top-area
|
||||
.row
|
||||
.col-sm-6
|
||||
|
@ -16,13 +16,68 @@
|
|||
.col-sm-6
|
||||
.nav-controls
|
||||
= render 'projects/deployments/actions', deployment: @environment.last_deployment
|
||||
.row
|
||||
.col-sm-12
|
||||
%h4
|
||||
CPU utilization
|
||||
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
|
||||
.row
|
||||
.col-sm-12
|
||||
%h4
|
||||
Memory usage
|
||||
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
|
||||
.prometheus-state
|
||||
.js-getting-started.hidden
|
||||
.row
|
||||
.col-md-4.col-md-offset-4.state-svg
|
||||
= render "shared/empty_states/monitoring/getting_started.svg"
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
%h4.text-center.state-title
|
||||
Get started with performance monitoring
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
.description-text.text-center.state-description
|
||||
Stay updated about the performance and health of your environment by configuring Prometheus to monitor your deployments.
|
||||
= link_to help_page_path('administration/monitoring/prometheus/index.md') do
|
||||
Learn more about performance monitoring
|
||||
.row.state-button-section
|
||||
.col-md-4.col-md-offset-4.text-center.state-button
|
||||
= link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus'), class: 'btn btn-success' do
|
||||
Configure Prometheus
|
||||
.js-loading.hidden
|
||||
.row
|
||||
.col-md-4.col-md-offset-4.state-svg
|
||||
= render "shared/empty_states/monitoring/loading.svg"
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
%h4.text-center.state-title
|
||||
Waiting for performance data
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
.description-text.text-center.state-description
|
||||
Creating graphs uses the data from the Prometheus server. If this takes a long time, ensure that data is available.
|
||||
.row.state-button-section
|
||||
.col-md-4.col-md-offset-4.text-center.state-button
|
||||
= link_to help_page_path('administration/monitoring/prometheus/index.md'), class: 'btn btn-success' do
|
||||
View documentation
|
||||
.js-unable-to-connect.hidden
|
||||
.row
|
||||
.col-md-4.col-md-offset-4.state-svg
|
||||
= render "shared/empty_states/monitoring/unable_to_connect.svg"
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
%h4.text-center.state-title
|
||||
Unable to connect to Prometheus server
|
||||
.row
|
||||
.col-md-6.col-md-offset-3
|
||||
.description-text.text-center.state-description
|
||||
Ensure connectivity is available from the GitLab server to the
|
||||
= link_to edit_namespace_project_service_path(@project.namespace, @project, 'prometheus') do
|
||||
Prometheus server
|
||||
.row.state-button-section
|
||||
.col-md-4.col-md-offset-4.text-center.state-button
|
||||
= link_to help_page_path('administration/monitoring/prometheus/index.md'), class:'btn btn-success' do
|
||||
View documentation
|
||||
|
||||
.prometheus-graphs
|
||||
.row
|
||||
.col-sm-12
|
||||
%h4
|
||||
CPU utilization
|
||||
%svg.prometheus-graph{ 'graph-type' => 'cpu_values' }
|
||||
.row
|
||||
.col-sm-12
|
||||
%h4
|
||||
Memory usage
|
||||
%svg.prometheus-graph{ 'graph-type' => 'memory_values' }
|
||||
|
|
|
@ -4,7 +4,6 @@
|
|||
- retried = local_assigns.fetch(:retried, false)
|
||||
- pipeline_link = local_assigns.fetch(:pipeline_link, false)
|
||||
- stage = local_assigns.fetch(:stage, false)
|
||||
- coverage = local_assigns.fetch(:coverage, false)
|
||||
|
||||
%tr.generic_commit_status{ class: ('retried' if retried) }
|
||||
%td.status
|
||||
|
@ -80,7 +79,7 @@
|
|||
%span= time_ago_with_tooltip(generic_commit_status.finished_at)
|
||||
|
||||
%td.coverage
|
||||
- if coverage && generic_commit_status.try(:coverage)
|
||||
- if generic_commit_status.try(:coverage)
|
||||
#{generic_commit_status.coverage}%
|
||||
|
||||
%td
|
||||
|
|
|
@ -59,7 +59,9 @@
|
|||
- if note.emoji_awardable?
|
||||
= link_to '#', title: 'Award Emoji', class: 'note-action-button note-emoji-button js-add-award js-note-emoji', data: { position: 'right' } do
|
||||
= icon('spinner spin')
|
||||
= icon('smile-o', class: 'link-highlight')
|
||||
%span{ class: "link-highlight award-control-icon-neutral" }= custom_icon('emoji_slightly_smiling_face')
|
||||
%span{ class: "link-highlight award-control-icon-positive" }= custom_icon('emoji_smiley')
|
||||
%span{ class: "link-highlight award-control-icon-super-positive" }= custom_icon('emoji_smile')
|
||||
|
||||
- if note_editable
|
||||
= link_to '#', title: 'Edit comment', class: 'note-action-button js-note-edit' do
|
||||
|
|
|
@ -36,7 +36,6 @@
|
|||
%th Job ID
|
||||
%th Name
|
||||
%th
|
||||
- if pipeline.project.build_coverage_enabled?
|
||||
%th Coverage
|
||||
%th Coverage
|
||||
%th
|
||||
= render partial: "projects/stage/stage", collection: pipeline.stages, as: :stage
|
||||
|
|
32
app/views/projects/registry/repositories/_image.html.haml
Normal file
|
@ -0,0 +1,32 @@
|
|||
.container-image.js-toggle-container
|
||||
.container-image-head
|
||||
= link_to "#", class: "js-toggle-button" do
|
||||
= icon('chevron-down', 'aria-hidden': 'true')
|
||||
= escape_once(image.path)
|
||||
|
||||
= clipboard_button(clipboard_text: "docker pull #{image.path}")
|
||||
|
||||
.controls.hidden-xs.pull-right
|
||||
= link_to namespace_project_container_registry_path(@project.namespace, @project, image),
|
||||
class: 'btn btn-remove has-tooltip',
|
||||
title: 'Remove repository',
|
||||
data: { confirm: 'Are you sure?' },
|
||||
method: :delete do
|
||||
= icon('trash cred', 'aria-hidden': 'true')
|
||||
|
||||
.container-image-tags.js-toggle-content.hide
|
||||
- if image.has_tags?
|
||||
.table-holder
|
||||
%table.table.tags
|
||||
%thead
|
||||
%tr
|
||||
%th Tag
|
||||
%th Tag ID
|
||||
%th Size
|
||||
%th Created
|
||||
- if can?(current_user, :update_container_image, @project)
|
||||
%th
|
||||
= render partial: 'tag', collection: image.tags
|
||||
- else
|
||||
.nothing-here-block No tags in Container Registry for this container image.
|
||||
|
|
@ -25,5 +25,9 @@
|
|||
- if can?(current_user, :update_container_image, @project)
|
||||
%td.content
|
||||
.controls.hidden-xs.pull-right
|
||||
= link_to namespace_project_container_registry_path(@project.namespace, @project, tag.name), class: 'btn btn-remove has-tooltip', title: "Remove", data: { confirm: "Are you sure?" }, method: :delete do
|
||||
= icon("trash cred")
|
||||
= link_to namespace_project_registry_repository_tag_path(@project.namespace, @project, tag.repository, tag.name),
|
||||
method: :delete,
|
||||
class: 'btn btn-remove has-tooltip',
|
||||
title: 'Remove tag',
|
||||
data: { confirm: 'Are you sure you want to delete this tag?' } do
|
||||
= icon('trash cred')
|
|
@ -15,25 +15,12 @@
|
|||
%br
|
||||
Then you are free to create and upload a container image with build and push commands:
|
||||
%pre
|
||||
docker build -t #{escape_once(@project.container_registry_repository_url)} .
|
||||
docker build -t #{escape_once(@project.container_registry_url)}/image .
|
||||
%br
|
||||
docker push #{escape_once(@project.container_registry_repository_url)}
|
||||
docker push #{escape_once(@project.container_registry_url)}/image
|
||||
|
||||
- if @tags.blank?
|
||||
%li
|
||||
.nothing-here-block No images in Container Registry for this project.
|
||||
- if @images.blank?
|
||||
.nothing-here-block No container image repositories in Container Registry for this project.
|
||||
|
||||
- else
|
||||
.table-holder
|
||||
%table.table.tags
|
||||
%thead
|
||||
%tr
|
||||
%th Name
|
||||
%th Image ID
|
||||
%th Size
|
||||
%th Created
|
||||
- if can?(current_user, :update_container_image, @project)
|
||||
%th
|
||||
|
||||
- @tags.each do |tag|
|
||||
= render 'tag', tag: tag
|
||||
= render partial: 'image', collection: @images
|
|
@ -6,8 +6,8 @@
|
|||
= ci_icon_for_status(stage.status)
|
||||
|
||||
= stage.name.titleize
|
||||
= render stage.statuses.latest_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, allow_retry: true
|
||||
= render stage.statuses.retried_ordered, coverage: @project.build_coverage_enabled?, stage: false, ref: false, pipeline_link: false, retried: true
|
||||
= render stage.statuses.latest_ordered, stage: false, ref: false, pipeline_link: false, allow_retry: true
|
||||
= render stage.statuses.retried_ordered, stage: false, ref: false, pipeline_link: false, retried: true
|
||||
%tr
|
||||
%td{ colspan: 10 }
|
||||
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 406 305" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><rect id="0" width="159.8" height="127.81" x=".196" y="5" rx="10"/><rect id="2" width="160" height="128" x=".666" y=".41" rx="10"/><rect id="4" width="160.19" height="128.19" x=".339" y=".59" rx="10"/><mask id="1" width="159.8" height="127.81" x="0" y="0" fill="#fff"><use xlink:href="#0"/></mask><mask id="3" width="160" height="128" x="0" y="0" fill="#fff"><use xlink:href="#2"/></mask><mask id="5" width="160.19" height="128.19" x="0" y="0" fill="#fff"><use xlink:href="#4"/></mask></defs><g fill="none" fill-rule="evenodd" transform="translate(12 3)"><rect width="160" height="128" x="122.08" y="146.08" fill="#f9f9f9" transform="matrix(.99619.08716-.08716.99619 19.08-16.813)" rx="10"/><g transform="matrix(.96593.25882-.25882.96593 227.1 57.47)"><rect width="159.8" height="127.81" x="1.64" y="10.06" fill="#f9f9f9" rx="8"/><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#1)" xlink:href="#0"/><g transform="translate(24.368 36.951)"><path fill="#d2caea" fill-rule="nonzero" d="m71.785 44.2c.761.296 1.625.099 2.184-.496l35.956-38.34c.756-.806.715-2.071-.091-2.827-.806-.756-2.071-.715-2.827.091l-35.03 37.36-41.888-16.285c-.749-.291-1.6-.106-2.16.471l-26.368 27.16c-.769.793-.751 2.059.042 2.828.793.769 2.059.751 2.828-.042l25.444-26.21 41.911 16.294"/><g fill="#fff"><circle cx="5.716" cy="5.104" r="5" stroke="#6b4fbb" stroke-width="4" transform="translate(65.917 34.945)"/><g stroke="#fb722e"><ellipse cx="4.632" cy="50.05" stroke-width="3.2" rx="4" ry="3.999"/><g stroke-width="4"><ellipse cx="29.632" cy="27.05" rx="4" ry="3.999"/><ellipse cx="107.63" cy="4.048" rx="4" ry="3.999"/></g></g></g></g></g><rect width="160.19" height="128.19" x="36.28" y="86.74" fill="#f9f9f9" transform="matrix(.99619-.08716.08716.99619-12.703 10.717)" rx="10"/><g transform="matrix(.99619.08716-.08716.99619 126.61 137.8)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#3)" xlink:href="#2"/><path fill="#6b4fbb" stroke="#6b4fbb" stroke-width="3.2" d="m84.67 28.41c18.225 0 33 15.07 33 33.651h-33v-33.651" stroke-linecap="round" stroke-linejoin="round"/><path fill="#d2caea" fill-rule="nonzero" d="m78.67 66.41h30c1.105 0 2 .895 2 2 0 18.778-15.222 34-34 34-18.778 0-34-15.222-34-34 0-18.778 15.222-34 34-34 1.105 0 2 .895 2 2v30m-32 2c0 16.569 13.431 30 30 30 15.896 0 28.905-12.364 29.934-28h-29.934c-1.105 0-2-.895-2-2v-29.934c-15.636 1.029-28 14.04-28 29.934"/></g><g transform="matrix(.99619-.08716.08716.99619 30 88.03)"><use fill="#fff" stroke="#eee" stroke-width="8" mask="url(#5)" xlink:href="#4"/><g transform="translate(42 34)"><path fill="#fef0ea" d="m0 13.391c0-.768.628-1.391 1.4-1.391h9.2c.773 0 1.4.626 1.4 1.391v49.609h-12v-49.609"/><path fill="#fb722e" d="m66 21.406c0-.777.628-1.406 1.4-1.406h9.2c.773 0 1.4.624 1.4 1.406v41.594h-12v-41.594"/><path fill="#6b4fbb" d="m22 1.404c0-.776.628-1.404 1.4-1.404h9.2c.773 0 1.4.624 1.4 1.404v61.6h-12v-61.6"/><path fill="#d2caea" d="m44 39.4c0-.772.628-1.398 1.4-1.398h9.2c.773 0 1.4.618 1.4 1.398v23.602h-12v-23.602"/></g></g><g fill="#fee8dc"><path d="m6.226 94.95l-2.84.631c-1.075.239-1.752-.445-1.515-1.515l.631-2.84-.631-2.84c-.239-1.075.445-1.752 1.515-1.515l2.84.631 2.84-.631c1.075-.239 1.752.445 1.515 1.515l-.631 2.84.631 2.84c.239 1.075-.445 1.752-1.515 1.515l-2.84-.631" transform="matrix(.70711.70711-.70711.70711 66.33 22.317)"/><path d="m312.78 53.43l-3.634.807c-1.296.288-2.115-.52-1.825-1.825l.807-3.634-.807-3.634c-.288-1.296.52-2.115 1.825-1.825l3.634.807 3.634-.807c1.296-.288 2.115.52 1.825 1.825l-.807 3.634.807 3.634c.288 1.296-.52 2.115-1.825 1.825l-3.634-.807" transform="matrix(.70711.70711-.70711.70711 126.1-206.88)"/></g><path fill="#e1dcf1" d="m124.78 12.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711 31.05 90.51)"/><path fill="#d2caea" d="m374.78 244.43l-3.617.804c-1.306.29-2.129-.53-1.839-1.839l.804-3.617-.804-3.617c-.29-1.306.53-2.129 1.839-1.839l3.617.804 3.617-.804c1.306-.29 2.129.53 1.839 1.839l-.804 3.617.804 3.617c.29 1.306-.53 2.129-1.839 1.839l-3.617-.804" transform="matrix(.70711-.70711.70711.70711-59.779 335.24)"/></g></svg>
|
After Width: | Height: | Size: 4.3 KiB |
1
app/views/shared/empty_states/monitoring/_loading.svg
Normal file
After Width: | Height: | Size: 6.1 KiB |
After Width: | Height: | Size: 5.6 KiB |
1
app/views/shared/icons/_code_fork.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M672 1472q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm0-1152q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm640 128q0-40-28-68t-68-28-68 28-28 68 28 68 68 28 68-28 28-68zm96 0q0 52-26 96.5t-70 69.5q-2 287-226 414-68 38-203 81-128 40-169.5 71t-41.5 100v26q44 25 70 69.5t26 96.5q0 80-56 136t-136 56-136-56-56-136q0-52 26-96.5t70-69.5v-820q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136q0 52-26 96.5t-70 69.5v497q54-26 154-57 55-17 87.5-29.5t70.5-31 59-39.5 40.5-51 28-69.5 8.5-91.5q-44-25-70-69.5t-26-96.5q0-80 56-136t136-56 136 56 56 136z"/></svg>
|
After Width: | Height: | Size: 675 B |
1
app/views/shared/icons/_comment_o.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M896 384q-204 0-381.5 69.5t-282 187.5-104.5 255q0 112 71.5 213.5t201.5 175.5l87 50-27 96q-24 91-70 172 152-63 275-171l43-38 57 6q69 8 130 8 204 0 381.5-69.5t282-187.5 104.5-255-104.5-255-282-187.5-381.5-69.5zm896 512q0 174-120 321.5t-326 233-450 85.5q-70 0-145-8-198 175-460 242-49 14-114 22h-5q-15 0-27-10.5t-16-27.5v-1q-3-4-.5-12t2-10 4.5-9.5l6-9 7-8.5 8-9q7-8 31-34.5t34.5-38 31-39.5 32.5-51 27-59 26-76q-157-89-247.5-220t-90.5-281q0-174 120-321.5t326-233 450-85.5 450 85.5 326 233 120 321.5z"/></svg>
|
After Width: | Height: | Size: 604 B |
1
app/views/shared/icons/_emoji_slightly_smiling_face.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369.721.721 0 0 1 .568.047.715.715 0 0 1 .37.445c.195.625.556 1.131 1.084 1.518A2.93 2.93 0 0 0 9 12.75a2.93 2.93 0 0 0 1.775-.58 2.913 2.913 0 0 0 1.084-1.518.711.711 0 0 1 .375-.445.737.737 0 0 1 .575-.047c.195.063.34.186.433.37.094.183.11.372.047.568zM7.5 6c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 4.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm6 0c0 .414-.146.768-.44 1.06-.292.294-.646.44-1.06.44-.414 0-.768-.146-1.06-.44A1.445 1.445 0 0 1 10.5 6c0-.414.146-.768.44-1.06.292-.294.646-.44 1.06-.44.414 0 .768.146 1.06.44.294.292.44.646.44 1.06zm3 3a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1.7 KiB |
1
app/views/shared/icons/_emoji_smile.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg width="18" height="18" viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg"><path d="M13.29 11.098a4.328 4.328 0 0 1-1.618 2.285c-.79.578-1.68.867-2.672.867-.992 0-1.883-.29-2.672-.867a4.328 4.328 0 0 1-1.617-2.285.721.721 0 0 1 .047-.569.715.715 0 0 1 .445-.369c.195-.062 7.41-.062 7.606 0 .195.063.34.186.433.37.094.183.11.372.047.568zM14 6.37c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm-6.5 0c0 .398-.04.755-.513.755-.473 0-.498-.272-1.237-.272-.74 0-.74.215-1.165.215-.425 0-.585-.3-.585-.698 0-.397.17-.736.513-1.017.341-.281.754-.422 1.237-.422.483 0 .896.14 1.237.422.342.28.513.62.513 1.017zm9 2.63a7.29 7.29 0 0 0-.598-2.912 7.574 7.574 0 0 0-1.6-2.39 7.574 7.574 0 0 0-2.39-1.6A7.29 7.29 0 0 0 9 1.5a7.29 7.29 0 0 0-2.912.598 7.574 7.574 0 0 0-2.39 1.6 7.574 7.574 0 0 0-1.6 2.39A7.29 7.29 0 0 0 1.5 9c0 1.016.2 1.986.598 2.912a7.574 7.574 0 0 0 1.6 2.39 7.574 7.574 0 0 0 2.39 1.6A7.29 7.29 0 0 0 9 16.5a7.29 7.29 0 0 0 2.912-.598 7.574 7.574 0 0 0 2.39-1.6 7.574 7.574 0 0 0 1.6-2.39A7.29 7.29 0 0 0 16.5 9zM18 9a8.804 8.804 0 0 1-1.207 4.518 8.96 8.96 0 0 1-3.275 3.275A8.804 8.804 0 0 1 9 18a8.804 8.804 0 0 1-4.518-1.207 8.96 8.96 0 0 1-3.275-3.275A8.804 8.804 0 0 1 0 9c0-1.633.402-3.139 1.207-4.518a8.96 8.96 0 0 1 3.275-3.275A8.804 8.804 0 0 1 9 0c1.633 0 3.139.402 4.518 1.207a8.96 8.96 0 0 1 3.275 3.275A8.804 8.804 0 0 1 18 9z" fill-rule="evenodd"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |