Commit 084a87ae authored by Sean McGivern's avatar Sean McGivern

Merge branch 'master' into 'ee-2246-uuid-is-nil-for-new-installation'

# Conflicts:
#   db/schema.rb
parents 0dcd6e98 6cad4b0b
...@@ -50,7 +50,7 @@ eslint-report.html ...@@ -50,7 +50,7 @@ eslint-report.html
/tags /tags
/tmp/* /tmp/*
/vendor/bundle/* /vendor/bundle/*
/builds/* /builds*
/shared/* /shared/*
/.gitlab_workhorse_secret /.gitlab_workhorse_secret
/webpack-report/ /webpack-report/
...@@ -72,6 +72,16 @@ stages: ...@@ -72,6 +72,16 @@ stages:
- //@gitlab-org/gitlab-ee - //@gitlab-org/gitlab-ee
- //@gitlab/gitlab-ee - //@gitlab/gitlab-ee
.only-master-and-ee-or-mysql: &only-master-and-ee-or-mysql
only:
- /mysql/
- master@gitlab-org/gitlab-ce
- master@gitlab/gitlabhq
- tags@gitlab-org/gitlab-ce
- tags@gitlab/gitlabhq
- //@gitlab-org/gitlab-ee
- //@gitlab/gitlab-ee
.rspec-knapsack: &rspec-knapsack .rspec-knapsack: &rspec-knapsack
stage: test stage: test
<<: *dedicated-runner <<: *dedicated-runner
......
...@@ -545,7 +545,7 @@ Style/Proc: ...@@ -545,7 +545,7 @@ Style/Proc:
# branches, and conditions. # branches, and conditions.
Metrics/AbcSize: Metrics/AbcSize:
Enabled: true Enabled: true
Max: 60 Max: 57.08
# This cop checks if the length of a block exceeds some maximum value. # This cop checks if the length of a block exceeds some maximum value.
Metrics/BlockLength: Metrics/BlockLength:
...@@ -564,7 +564,7 @@ Metrics/ClassLength: ...@@ -564,7 +564,7 @@ Metrics/ClassLength:
# of test cases needed to validate a method. # of test cases needed to validate a method.
Metrics/CyclomaticComplexity: Metrics/CyclomaticComplexity:
Enabled: true Enabled: true
Max: 17 Max: 16
# Limit lines to 80 characters. # Limit lines to 80 characters.
Metrics/LineLength: Metrics/LineLength:
......
...@@ -11,6 +11,7 @@ entry. ...@@ -11,6 +11,7 @@ entry.
- Fix lastest commit status text on main project page. !10863 - Fix lastest commit status text on main project page. !10863
- Add index on ci_builds.updated_at. !10870 (blackst0ne) - Add index on ci_builds.updated_at. !10870 (blackst0ne)
- Fix 500 error due to trying to show issues from pending deleting projects. !10906 - Fix 500 error due to trying to show issues from pending deleting projects. !10906
- Ensures that OAuth/LDAP/SAML users don't need to be confirmed.
- Ensure replying to an individual note by email creates a note with its own discussion ID. - Ensure replying to an individual note by email creates a note with its own discussion ID.
- Fix OAuth, LDAP and SAML SSO when regular sign-ups are disabled. - Fix OAuth, LDAP and SAML SSO when regular sign-ups are disabled.
- Fix usage ping docs link from empty cohorts page. - Fix usage ping docs link from empty cohorts page.
......
...@@ -17,6 +17,8 @@ gem 'pg', '~> 0.18.2', group: :postgres ...@@ -17,6 +17,8 @@ gem 'pg', '~> 0.18.2', group: :postgres
gem 'rugged', '~> 0.25.1.1' gem 'rugged', '~> 0.25.1.1'
gem 'faraday', '~> 0.11.0'
# Authentication libraries # Authentication libraries
gem 'devise', '~> 4.2' gem 'devise', '~> 4.2'
gem 'doorkeeper', '~> 4.2.0' gem 'doorkeeper', '~> 4.2.0'
...@@ -196,7 +198,7 @@ gem 'gemnasium-gitlab-service', '~> 0.2' ...@@ -196,7 +198,7 @@ gem 'gemnasium-gitlab-service', '~> 0.2'
gem 'slack-notifier', '~> 1.5.1' gem 'slack-notifier', '~> 1.5.1'
# Asana integration # Asana integration
gem 'asana', '~> 0.4.0' gem 'asana', '~> 0.6.0'
# FogBugz integration # FogBugz integration
gem 'ruby-fogbugz', '~> 0.2.1' gem 'ruby-fogbugz', '~> 0.2.1'
...@@ -356,7 +358,7 @@ gem 'html2text' ...@@ -356,7 +358,7 @@ gem 'html2text'
gem 'ruby-prof', '~> 0.16.2' gem 'ruby-prof', '~> 0.16.2'
# OAuth # OAuth
gem 'oauth2', '~> 1.2.0' gem 'oauth2', '~> 1.3.0'
# Soft deletion # Soft deletion
gem 'paranoia', '~> 2.2' gem 'paranoia', '~> 2.2'
......
...@@ -47,7 +47,7 @@ GEM ...@@ -47,7 +47,7 @@ GEM
akismet (2.0.0) akismet (2.0.0)
allocations (1.0.5) allocations (1.0.5)
arel (6.0.4) arel (6.0.4)
asana (0.4.0) asana (0.6.0)
faraday (~> 0.9) faraday (~> 0.9)
faraday_middleware (~> 0.9) faraday_middleware (~> 0.9)
faraday_middleware-multi_json (~> 0.0) faraday_middleware-multi_json (~> 0.0)
...@@ -214,10 +214,10 @@ GEM ...@@ -214,10 +214,10 @@ GEM
factory_girl_rails (4.7.0) factory_girl_rails (4.7.0)
factory_girl (~> 4.7.0) factory_girl (~> 4.7.0)
railties (>= 3.0.0) railties (>= 3.0.0)
faraday (0.9.2) faraday (0.11.0)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday_middleware (0.10.0) faraday_middleware (0.11.0.1)
faraday (>= 0.7.4, < 0.10) faraday (>= 0.7.4, < 1.0)
faraday_middleware-aws-signers-v4 (0.1.5) faraday_middleware-aws-signers-v4 (0.1.5)
aws-sdk (~> 2.1) aws-sdk (~> 2.1)
faraday (~> 0.9) faraday (~> 0.9)
...@@ -490,15 +490,15 @@ GEM ...@@ -490,15 +490,15 @@ GEM
mini_portile2 (~> 2.1.0) mini_portile2 (~> 2.1.0)
numerizer (0.1.1) numerizer (0.1.1)
oauth (0.5.1) oauth (0.5.1)
oauth2 (1.2.0) oauth2 (1.3.1)
faraday (>= 0.8, < 0.10) faraday (>= 0.8, < 0.12)
jwt (~> 1.0) jwt (~> 1.0)
multi_json (~> 1.3) multi_json (~> 1.3)
multi_xml (~> 0.5) multi_xml (~> 0.5)
rack (>= 1.2, < 3) rack (>= 1.2, < 3)
octokit (4.6.2) octokit (4.6.2)
sawyer (~> 0.8.0, >= 0.5.3) sawyer (~> 0.8.0, >= 0.5.3)
oj (2.17.4) oj (2.17.5)
omniauth (1.4.2) omniauth (1.4.2)
hashie (>= 1.2, < 4) hashie (>= 1.2, < 4)
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
...@@ -752,7 +752,7 @@ GEM ...@@ -752,7 +752,7 @@ GEM
rack rack
shoulda-matchers (2.8.0) shoulda-matchers (2.8.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
sidekiq (4.2.7) sidekiq (4.2.10)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
connection_pool (~> 2.2, >= 2.2.0) connection_pool (~> 2.2, >= 2.2.0)
rack-protection (>= 1.5.0) rack-protection (>= 1.5.0)
...@@ -889,7 +889,7 @@ DEPENDENCIES ...@@ -889,7 +889,7 @@ DEPENDENCIES
after_commit_queue (~> 1.3.0) after_commit_queue (~> 1.3.0)
akismet (~> 2.0) akismet (~> 2.0)
allocations (~> 1.0) allocations (~> 1.0)
asana (~> 0.4.0) asana (~> 0.6.0)
asciidoctor (~> 1.5.2) asciidoctor (~> 1.5.2)
asciidoctor-plantuml (= 0.0.7) asciidoctor-plantuml (= 0.0.7)
attr_encrypted (~> 3.0.0) attr_encrypted (~> 3.0.0)
...@@ -931,6 +931,7 @@ DEPENDENCIES ...@@ -931,6 +931,7 @@ DEPENDENCIES
email_reply_trimmer (~> 0.1) email_reply_trimmer (~> 0.1)
email_spec (~> 1.6.0) email_spec (~> 1.6.0)
factory_girl_rails (~> 4.7.0) factory_girl_rails (~> 4.7.0)
faraday (~> 0.11.0)
faraday_middleware-aws-signers-v4 faraday_middleware-aws-signers-v4
ffaker (~> 2.4) ffaker (~> 2.4)
flay (~> 2.8.0) flay (~> 2.8.0)
...@@ -988,7 +989,7 @@ DEPENDENCIES ...@@ -988,7 +989,7 @@ DEPENDENCIES
net-ldap net-ldap
net-ssh (~> 3.0.1) net-ssh (~> 3.0.1)
nokogiri (~> 1.6.7, >= 1.6.7.2) nokogiri (~> 1.6.7, >= 1.6.7.2)
oauth2 (~> 1.2.0) oauth2 (~> 1.3.0)
octokit (~> 4.6.2) octokit (~> 4.6.2)
oj (~> 2.17.4) oj (~> 2.17.4)
omniauth (~> 1.4.2) omniauth (~> 1.4.2)
......
...@@ -103,7 +103,7 @@ One small thing you also have to do when installing it yourself is to copy the e ...@@ -103,7 +103,7 @@ One small thing you also have to do when installing it yourself is to copy the e
cp config/unicorn.rb.example.development config/unicorn.rb cp config/unicorn.rb.example.development config/unicorn.rb
Instructions on how to start GitLab and how to run the tests can be found in the [development section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#development). Instructions on how to start GitLab and how to run the tests can be found in the [getting started section of the GitLab Development Kit](https://gitlab.com/gitlab-org/gitlab-development-kit#getting-started).
## Software stack ## Software stack
......
/* eslint-disable no-new */ /* eslint-disable no-new */
import Vue from 'vue'; import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import NotebookLab from 'vendor/notebooklab'; import notebookLab from '../../notebook/index.vue';
Vue.use(VueResource); Vue.use(VueResource);
Vue.use(NotebookLab);
export default () => { export default () => {
const el = document.getElementById('js-notebook-viewer'); const el = document.getElementById('js-notebook-viewer');
...@@ -19,6 +18,9 @@ export default () => { ...@@ -19,6 +18,9 @@ export default () => {
json: {}, json: {},
}; };
}, },
components: {
notebookLab,
},
template: ` template: `
<div class="container-fluid md prepend-top-default append-bottom-default"> <div class="container-fluid md prepend-top-default append-bottom-default">
<div <div
......
...@@ -31,7 +31,7 @@ export default () => { ...@@ -31,7 +31,7 @@ export default () => {
}, },
}, },
template: ` template: `
<div class="container-fluid md prepend-top-default append-bottom-default"> <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default">
<div <div
class="text-center loading" class="text-center loading"
v-if="loading && !error"> v-if="loading && !error">
......
/* global Flash */
export default class BlobViewer {
constructor() {
this.switcher = document.querySelector('.js-blob-viewer-switcher');
this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn');
this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn');
this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]');
this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]');
this.$blobContentHolder = $('#blob-content-holder');
let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type');
this.initBindings();
if (this.switcher && location.hash.indexOf('#L') === 0) {
initialViewerName = 'simple';
}
this.switchToViewer(initialViewerName);
}
initBindings() {
if (this.switcherBtns.length) {
Array.from(this.switcherBtns)
.forEach((el) => {
el.addEventListener('click', this.switchViewHandler.bind(this));
});
}
if (this.copySourceBtn) {
this.copySourceBtn.addEventListener('click', () => {
if (this.copySourceBtn.classList.contains('disabled')) return;
this.switchToViewer('simple');
});
}
}
switchViewHandler(e) {
const target = e.currentTarget;
e.preventDefault();
this.switchToViewer(target.getAttribute('data-viewer'));
}
toggleCopyButtonState() {
if (!this.copySourceBtn) return;
if (this.simpleViewer.getAttribute('data-loaded')) {
this.copySourceBtn.setAttribute('title', 'Copy source to clipboard');
this.copySourceBtn.classList.remove('disabled');
} else if (this.activeViewer === this.simpleViewer) {
this.copySourceBtn.setAttribute('title', 'Wait for the source to load to copy it to the clipboard');
this.copySourceBtn.classList.add('disabled');
} else {
this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard');
this.copySourceBtn.classList.add('disabled');
}
$(this.copySourceBtn).tooltip('fixTitle');
}
loadViewer(viewerParam) {
const viewer = viewerParam;
const url = viewer.getAttribute('data-url');
if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) {
return;
}
viewer.setAttribute('data-loading', 'true');
$.ajax({
url,
dataType: 'JSON',
})
.fail(() => new Flash('Error loading source view'))
.done((data) => {
viewer.innerHTML = data.html;
$(viewer).syntaxHighlight();
viewer.setAttribute('data-loaded', 'true');
this.$blobContentHolder.trigger('highlight:line');
this.toggleCopyButtonState();
});
}
switchToViewer(name) {
const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`);
if (this.activeViewer === newViewer) return;
const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active');
const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`);
const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`);
if (oldButton) {
oldButton.classList.remove('active');
}
if (newButton) {
newButton.classList.add('active');
newButton.blur();
}
if (oldViewer) {
oldViewer.classList.add('hidden');
}
newViewer.classList.remove('hidden');
this.activeViewer = newViewer;
this.toggleCopyButtonState();
this.loadViewer(newViewer);
}
}
...@@ -51,6 +51,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; ...@@ -51,6 +51,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion';
import UserCallout from './user_callout'; import UserCallout from './user_callout';
import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags';
import ShortcutsWiki from './shortcuts_wiki'; import ShortcutsWiki from './shortcuts_wiki';
import BlobViewer from './blob/viewer/index';
import GeoNodes from './geo_nodes'; import GeoNodes from './geo_nodes';
import ServiceDeskRoot from './projects/settings_service_desk/service_desk_root'; import ServiceDeskRoot from './projects/settings_service_desk/service_desk_root';
...@@ -313,6 +314,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); ...@@ -313,6 +314,7 @@ const ShortcutsBlob = require('./shortcuts_blob');
gl.TargetBranchDropDown.bootstrap(); gl.TargetBranchDropDown.bootstrap();
break; break;
case 'projects:blob:show': case 'projects:blob:show':
new BlobViewer();
gl.TargetBranchDropDown.bootstrap(); gl.TargetBranchDropDown.bootstrap();
initBlob(); initBlob();
break; break;
......
<script>
/* eslint-disable no-new */ /* eslint-disable no-new */
/* global Flash */ /* global Flash */
import Vue from 'vue';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from './environments_table.vue'; import EnvironmentTable from './environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
...@@ -8,7 +9,7 @@ import TablePaginationComponent from '../../vue_shared/components/table_paginati ...@@ -8,7 +9,7 @@ import TablePaginationComponent from '../../vue_shared/components/table_paginati
import '../../lib/utils/common_utils'; import '../../lib/utils/common_utils';
import eventHub from '../event_hub'; import eventHub from '../event_hub';
export default Vue.component('environment-component', { export default {
components: { components: {
'environment-table': EnvironmentTable, 'environment-table': EnvironmentTable,
...@@ -162,78 +163,91 @@ export default Vue.component('environment-component', { ...@@ -162,78 +163,91 @@ export default Vue.component('environment-component', {
}); });
}, },
}, },
};
template: ` </script>
<div :class="cssContainerClass"> <template>
<div class="top-area"> <div :class="cssContainerClass">
<ul v-if="!isLoading" class="nav-links"> <div class="top-area">
<li v-bind:class="{ 'active': scope === null || scope === 'available' }"> <ul
<a :href="projectEnvironmentsPath"> v-if="!isLoading"
Available class="nav-links">
<span class="badge js-available-environments-count"> <li :class="{ active: scope === null || scope === 'available' }">
{{state.availableCounter}} <a :href="projectEnvironmentsPath">
</span> Available
</a> <span class="badge js-available-environments-count">
</li> {{state.availableCounter}}
<li v-bind:class="{ 'active' : scope === 'stopped' }"> </span>
<a :href="projectStoppedEnvironmentsPath"> </a>
Stopped </li>
<span class="badge js-stopped-environments-count"> <li :class="{ 'active' : scope === 'stopped' }">
{{state.stoppedCounter}} <a :href="projectStoppedEnvironmentsPath">
</span> Stopped
</a> <span class="badge js-stopped-environments-count">
</li> {{state.stoppedCounter}}
</ul> </span>
<div v-if="canCreateEnvironmentParsed && !isLoading" class="nav-controls">
<a :href="newEnvironmentPath" class="btn btn-create">
New environment
</a> </a>
</div> </li>
</ul>
<div
v-if="canCreateEnvironmentParsed && !isLoading"
class="nav-controls">
<a
:href="newEnvironmentPath"
class="btn btn-create">
New environment
</a>
</div>
</div>
<div class="content-list environments-container">
<div
class="environments-list-loading text-center"
v-if="isLoading">
<i
class="fa fa-spinner fa-spin"
aria-hidden="true" />
</div> </div>
<div class="content-list environments-container"> <div
<div class="environments-list-loading text-center" v-if="isLoading"> class="blank-state blank-state-no-icon"
<i class="fa fa-spinner fa-spin" aria-hidden="true"></i> v-if="!isLoading && state.environments.length === 0">
</div> <h2 class="blank-state-title js-blank-state-title">
You don't have any environments right now.
<div class="blank-state blank-state-no-icon" </h2>
v-if="!isLoading && state.environments.length === 0"> <p class="blank-state-text">
<h2 class="blank-state-title js-blank-state-title"> Environments are places where code gets deployed, such as staging or production.
You don't have any environments right now. <br />
</h2> <a :href="helpPagePath">
<p class="blank-state-text"> Read more about environments
Environments are places where code gets deployed, such as staging or production.
<br />
<a :href="helpPagePath">
Read more about environments
</a>
</p>
<a v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
class="btn btn-create js-new-environment-button">
New Environment
</a> </a>
</div> </p>
<div class="table-holder" <a
v-if="!isLoading && state.environments.length > 0"> v-if="canCreateEnvironmentParsed"
:href="newEnvironmentPath"
<environment-table class="btn btn-create js-new-environment-button">
:environments="state.environments" New Environment
:can-create-deployment="canCreateDeploymentParsed" </a>
:can-read-environment="canReadEnvironmentParsed"
:toggleDeployBoard="toggleDeployBoard"
:store="store"
:service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
:pageInfo="state.paginationInformation">
</table-pagination>
</div> </div>
<div
class="table-holder"
v-if="!isLoading && state.environments.length > 0">
<environment-table
:environments="state.environments"
:can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
:toggleDeployBoard="toggleDeployBoard"
:store="store"
:service="service"
:is-loading-folder-content="isLoadingFolderContent" />
</div>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
:pageInfo="state.paginationInformation" />
</div> </div>
`, </div>
}); </template>
import EnvironmentsComponent from './components/environment'; import Vue from 'vue';
import EnvironmentsComponent from './components/environment.vue';
$(() => { document.addEventListener('DOMContentLoaded', () => new Vue({
window.gl = window.gl || {}; el: '#environments-list-view',
components: {
if (gl.EnvironmentsListApp) { 'environments-table-app': EnvironmentsComponent,
gl.EnvironmentsListApp.$destroy(true); },
} render: createElement => createElement('environments-table-app'),
}));
gl.EnvironmentsListApp = new EnvironmentsComponent({
el: document.querySelector('#environments-list-view'),
});
});
import EnvironmentsFolderComponent from './environments_folder_view'; import Vue from 'vue';
import EnvironmentsFolderComponent from './environments_folder_view.vue';
$(() => { document.addEventListener('DOMContentLoaded', () => {
window.gl = window.gl || {}; // eslint-disable-next-line no-new
new Vue({
if (gl.EnvironmentsListFolderApp) { el: '#environments-folder-list-view',
gl.EnvironmentsListFolderApp.$destroy(true); components: {
} 'environments-folder-app': EnvironmentsFolderComponent,
},
gl.EnvironmentsListFolderApp = new EnvironmentsFolderComponent({ render: createElement => createElement('environments-folder-app'),
el: document.querySelector('#environments-folder-list-view'),
}); });
}); });
<script>
/* eslint-disable no-new */ /* eslint-disable no-new */
/* global Flash */ /* global Flash */
import Vue from 'vue';
import EnvironmentsService from '../services/environments_service'; import EnvironmentsService from '../services/environments_service';
import EnvironmentTable from '../components/environments_table.vue'; import EnvironmentTable from '../components/environments_table.vue';
import EnvironmentsStore from '../stores/environments_store'; import EnvironmentsStore from '../stores/environments_store';
...@@ -8,7 +8,7 @@ import TablePaginationComponent from '../../vue_shared/components/table_paginati ...@@ -8,7 +8,7 @@ import TablePaginationComponent from '../../vue_shared/components/table_paginati
import '../../lib/utils/common_utils'; import '../../lib/utils/common_utils';
import '../../vue_shared/vue_resource_interceptor'; import '../../vue_shared/vue_resource_interceptor';
export default Vue.component('environment-folder-view', { export default {
components: { components: {
'environment-table': EnvironmentTable, 'environment-table': EnvironmentTable,
'table-pagination': TablePaginationComponent, 'table-pagination': TablePaginationComponent,
...@@ -128,56 +128,68 @@ export default Vue.component('environment-folder-view', { ...@@ -128,56 +128,68 @@ export default Vue.component('environment-folder-view', {
return param; return param;
}, },
}, },
};
</script>
<template>
<div :class="cssContainerClass">
<div
class="top-area"
v-if="!isLoading">
<h4 class="js-folder-name environments-folder-name">
Environments / <b>{{folderName}}</b>
</h4>
<ul class="nav-links">
<li :class="{ 'active': scope === null || scope === 'available' }">
<a
:href="availablePath"
class="js-available-environments-folder-tab">
Available
<span class="badge js-available-environments-count">
{{state.availableCounter}}
</span>
</a>
</li>
<li :class="{ 'active' : scope === 'stopped' }">
<a
:href="stoppedPath"
class="js-stopped-environments-folder-tab">
Stopped
<span class="badge js-stopped-environments-count">
{{state.stoppedCounter}}
</span>
</a>
</li>
</ul>
</div>
template: ` <div class="environments-container">
<div :class="cssContainerClass"> <div
<div class="top-area" v-if="!isLoading"> class="environments-list-loading text-center"
v-if="isLoading">
<h4 class="js-folder-name environments-folder-name"> <i
Environments / <b>{{folderName}}</b> class="fa fa-spinner fa-spin"
</h4> aria-hidden="true"/>
<ul class="nav-links">
<li v-bind:class="{ 'active': scope === null || scope === 'available' }">
<a :href="availablePath" class="js-available-environments-folder-tab">
Available
<span class="badge js-available-environments-count">
{{state.availableCounter}}
</span>
</a>
</li>
<li v-bind:class="{ 'active' : scope === 'stopped' }">
<a :href="stoppedPath" class="js-stopped-environments-folder-tab">
Stopped
<span class="badge js-stopped-environments-count">
{{state.stoppedCounter}}
</span>
</a>
</li>
</ul>
</div> </div>
<div class="environments-container"> <div
<div class="environments-list-loading text-center" v-if="isLoading"> class="table-holder"
<i class="fa fa-spinner fa-spin"></i> v-if="!isLoading && state.environments.length > 0">
</div>
<environment-table
<div class="table-holder" :environments="state.environments"
v-if="!isLoading && state.environments.length > 0"> :can-create-deployment="canCreateDeploymentParsed"
:can-read-environment="canReadEnvironmentParsed"
<environment-table :toggleDeployBoard="toggleDeployBoard"
:environments="state.environments" :store="store"
:can-create-deployment="canCreateDeploymentParsed" :service="service"/>
:can-read-environment="canReadEnvironmentParsed"
:toggleDeployBoard="toggleDeployBoard" <table-pagination
:store="store" v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:service="service"/> :change="changePage"
:pageInfo="state.paginationInformation"/>
<table-pagination v-if="state.paginationInformation && state.paginationInformation.totalPages > 1"
:change="changePage"
:pageInfo="state.paginationInformation"/>
</div>
</div> </div>
</div> </div>
`, </div>
}); </template>
...@@ -41,7 +41,6 @@ require('vendor/jquery.scrollTo'); ...@@ -41,7 +41,6 @@ require('vendor/jquery.scrollTo');
LineHighlighter.prototype._hash = ''; LineHighlighter.prototype._hash = '';
function LineHighlighter(hash) { function LineHighlighter(hash) {
var range;
if (hash == null) { if (hash == null) {
// Initialize a LineHighlighter object // Initialize a LineHighlighter object
// //
...@@ -51,10 +50,22 @@ require('vendor/jquery.scrollTo'); ...@@ -51,10 +50,22 @@ require('vendor/jquery.scrollTo');
this.setHash = bind(this.setHash, this); this.setHash = bind(this.setHash, this);
this.highlightLine = bind(this.highlightLine, this); this.highlightLine = bind(this.highlightLine, this);
this.clickHandler = bind(this.clickHandler, this); this.clickHandler = bind(this.clickHandler, this);
this.highlightHash = this.highlightHash.bind(this);
this._hash = hash; this._hash = hash;
this.bindEvents(); this.bindEvents();
if (hash !== '') { this.highlightHash();
range = this.hashToRange(hash); }
LineHighlighter.prototype.bindEvents = function() {
const $blobContentHolder = $('#blob-content-holder');
$blobContentHolder.on('click', 'a[data-line-number]', this.clickHandler);
$blobContentHolder.on('highlight:line', this.highlightHash);
};
LineHighlighter.prototype.highlightHash = function() {
var range;
if (this._hash !== '') {
range = this.hashToRange(this._hash);
if (range[0]) { if (range[0]) {
this.highlightRange(range); this.highlightRange(range);
$.scrollTo("#L" + range[0], { $.scrollTo("#L" + range[0], {
...@@ -64,10 +75,6 @@ require('vendor/jquery.scrollTo'); ...@@ -64,10 +75,6 @@ require('vendor/jquery.scrollTo');
}); });
} }
} }
}
LineHighlighter.prototype.bindEvents = function() {
$('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler);
}; };
LineHighlighter.prototype.clickHandler = function(event) { LineHighlighter.prototype.clickHandler = function(event) {
......
...@@ -22,6 +22,7 @@ class PrometheusGraph { ...@@ -22,6 +22,7 @@ class PrometheusGraph {
const hasMetrics = $prometheusContainer.data('has-metrics'); const hasMetrics = $prometheusContainer.data('has-metrics');
this.docLink = $prometheusContainer.data('doc-link'); this.docLink = $prometheusContainer.data('doc-link');
this.integrationLink = $prometheusContainer.data('prometheus-integration'); this.integrationLink = $prometheusContainer.data('prometheus-integration');
this.state = '';
$(document).ajaxError(() => {}); $(document).ajaxError(() => {});
...@@ -38,8 +39,9 @@ class PrometheusGraph { ...@@ -38,8 +39,9 @@ class PrometheusGraph {
this.configureGraph(); this.configureGraph();
this.init(); this.init();
} else { } else {
const prevState = this.state;
this.state = '.js-getting-started'; this.state = '.js-getting-started';
this.updateState(); this.updateState(prevState);
} }
} }
...@@ -53,26 +55,26 @@ class PrometheusGraph { ...@@ -53,26 +55,26 @@ class PrometheusGraph {
} }
init() { init() {
this.getData().then((metricsResponse) => { return this.getData().then((metricsResponse) => {
let enoughData = true; let enoughData = true;
Object.keys(metricsResponse.metrics).forEach((key) => { if (typeof metricsResponse === 'undefined') {
let currentKey; enoughData = false;
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 { } else {
Object.keys(metricsResponse.metrics).forEach((key) => {
if (key === 'cpu_values' || key === 'memory_values') {
const currentData = (metricsResponse.metrics[key])[0];
if (currentData.values.length <= 2) {
enoughData = false;
}
}
});
}
if (enoughData) {
$(prometheusStatesContainer).hide();
$(prometheusParentGraphContainer).show();
this.transformData(metricsResponse); this.transformData(metricsResponse);
this.createGraph(); this.createGraph();
} }
}).catch(() => {
new Flash('An error occurred when trying to load metrics. Please try again.');
}); });
} }
...@@ -342,6 +344,8 @@ class PrometheusGraph { ...@@ -342,6 +344,8 @@ class PrometheusGraph {
getData() { getData() {
const maxNumberOfRequests = 3; const maxNumberOfRequests = 3;
this.state = '.js-loading';
this.updateState();
return gl.utils.backOff((next, stop) => { return gl.utils.backOff((next, stop) => {
$.ajax({ $.ajax({
url: metricsEndpoint, url: metricsEndpoint,
...@@ -352,12 +356,11 @@ class PrometheusGraph { ...@@ -352,12 +356,11 @@ class PrometheusGraph {
this.backOffRequestCounter = this.backOffRequestCounter += 1; this.backOffRequestCounter = this.backOffRequestCounter += 1;
if (this.backOffRequestCounter < maxNumberOfRequests) { if (this.backOffRequestCounter < maxNumberOfRequests) {
next(); next();
} else { } else if (this.backOffRequestCounter >= maxNumberOfRequests) {
stop({ stop(new Error('loading'));
status: resp.status,
metrics: data,
});
} }
} else if (!data.success) {
stop(new Error('loading'));
} else { } else {
stop({ stop({
status: resp.status, status: resp.status,
...@@ -373,8 +376,9 @@ class PrometheusGraph { ...@@ -373,8 +376,9 @@ class PrometheusGraph {
return resp.metrics; return resp.metrics;
}) })
.catch(() => { .catch(() => {
const prevState = this.state;
this.state = '.js-unable-to-connect'; this.state = '.js-unable-to-connect';
this.updateState(); this.updateState(prevState);
}); });
} }
...@@ -382,19 +386,20 @@ class PrometheusGraph { ...@@ -382,19 +386,20 @@ class PrometheusGraph {
Object.keys(metricsResponse.metrics).forEach((key) => { Object.keys(metricsResponse.metrics).forEach((key) => {
if (key === 'cpu_values' || key === 'memory_values') { if (key === 'cpu_values' || key === 'memory_values') {
const metricValues = (metricsResponse.metrics[key])[0]; const metricValues = (metricsResponse.metrics[key])[0];
if (metricValues !== undefined) { this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({
this.graphSpecificProperties[key].data = metricValues.values.map(metric => ({ time: new Date(metric[0] * 1000),
time: new Date(metric[0] * 1000), value: metric[1],
value: metric[1], }));
}));
}
} }
}); });
} }
updateState() { updateState(prevState) {
const $statesContainer = $(prometheusStatesContainer); const $statesContainer = $(prometheusStatesContainer);
$(prometheusParentGraphContainer).hide(); $(prometheusParentGraphContainer).hide();
if (prevState) {
$(`${prevState}`, $statesContainer).addClass('hidden');
}
$(`${this.state}`, $statesContainer).removeClass('hidden'); $(`${this.state}`, $statesContainer).removeClass('hidden');
$(prometheusStatesContainer).show(); $(prometheusStatesContainer).show();
} }
......
<template>
<div class="cell">
<code-cell
type="input"
:raw-code="rawInputCode"
:count="cell.execution_count"
:code-css-class="codeCssClass" />
<output-cell
v-if="hasOutput"
:count="cell.execution_count"
:output="output"
:code-css-class="codeCssClass" />
</div>
</template>
<script>
import CodeCell from './code/index.vue';
import OutputCell from './output/index.vue';
export default {
components: {
'code-cell': CodeCell,
'output-cell': OutputCell,
},
props: {
cell: {
type: Object,
required: true,
},
codeCssClass: {
type: String,
required: false,
default: '',
},
},
computed: {
rawInputCode() {
if (this.cell.source) {
return this.cell.source.join('');
}
return '';
},
hasOutput() {
return this.cell.outputs.length;
},
output() {
return this.cell.outputs[0];
},
},
};
</script>
<style scoped>
.cell {
flex-direction: column;
}
</style>
<template>
<div :class="type">
<prompt
:type="promptType"
:count="count" />
<pre
class="language-python"
:class="codeCssClass"
ref="code"
v-text="code">
</pre>
</div>
</template>
<script>
import Prism from '../../lib/highlight';
import Prompt from '../prompt.vue';
export default {
components: {
prompt: Prompt,
},
props: {
count: {
type: Number,
required: false,
default: 0,
},
codeCssClass: {
type: String,
required: false,
default: '',
},
type: {
type: String,
required: true,
},
rawCode: {
type: String,
required: true,
},
},
computed: {
code() {
return this.rawCode;
},
promptType() {
const type = this.type.split('put')[0];
return type.charAt(0).toUpperCase() + type.slice(1);
},
},
mounted() {
Prism.highlightElement(this.$refs.code);
},
};
</script>
export { default as MarkdownCell } from './markdown.vue';
export { default as CodeCell } from './code.vue';
<template>
<div class="cell text-cell">
<prompt />
<div class="markdown" v-html="markdown"></div>
</div>
</template>
<script>
/* global katex */
import marked from 'marked';
import Prompt from './prompt.vue';
const renderer = new marked.Renderer();
/*
Regex to match KaTex blocks.
Supports the following:
\begin{equation}<math>\end{equation}
$$<math>$$
inline $<math>$
The matched text then goes through the KaTex renderer & then outputs the HTML
*/
const katexRegexString = `(
^\\\\begin{[a-zA-Z]+}\\s
|
^\\$\\$
|
\\s\\$(?!\\$)
)
(.+?)
(
\\s\\\\end{[a-zA-Z]+}$
|
\\$\\$$
|
\\$
)
`.replace(/\s/g, '').trim();
renderer.paragraph = (t) => {
let text = t;
let inline = false;
if (typeof katex !== 'undefined') {
const katexString = text.replace(/\\/g, '\\');
const matches = new RegExp(katexRegexString, 'gi').exec(katexString);
if (matches && matches.length > 0) {
if (matches[1].trim() === '$' && matches[3].trim() === '$') {
inline = true;
text = `${katexString.replace(matches[0], '')} ${katex.renderToString(matches[2])}`;
} else {
text = katex.renderToString(matches[2]);
}
}
}
return `<p class="${inline ? 'inline-katex' : ''}">${text}</p>`;
};
marked.setOptions({
sanitize: true,
renderer,
});
export default {
components: {
prompt: Prompt,
},
props: {
cell: {
type: Object,
required: true,
},
},
computed: {
markdown() {
return marked(this.cell.source.join(''));
},
},
};
</script>
<style>
.markdown .katex {
display: block;
text-align: center;
}
.markdown .inline-katex .katex {
display: inline;
text-align: initial;
}
</style>
<template>
<div class="output">
<prompt />
<div v-html="rawCode"></div>
</div>
</template>
<script>
import Prompt from '../prompt.vue';
export default {
props: {
rawCode: {
type: String,
required: true,
},
},
components: {
prompt: Prompt,
},
};
</script>
<template>
<div class="output">
<prompt />
<img
:src="'data:' + outputType + ';base64,' + rawCode" />
</div>
</template>
<script>
import Prompt from '../prompt.vue';
export default {
props: {
outputType: {
type: String,
required: true,
},
rawCode: {
type: String,
required: true,
},
},
components: {
prompt: Prompt,
},
};
</script>
<template>
<component :is="componentName"
type="output"
:outputType="outputType"
:count="count"
:raw-code="rawCode"
:code-css-class="codeCssClass" />
</template>
<script>
import CodeCell from '../code/index.vue';
import Html from './html.vue';
import Image from './image.vue';
export default {
props: {
codeCssClass: {
type: String,
required: false,
default: '',
},
count: {
type: Number,
required: false,
default: 0,
},
output: {
type: Object,
requred: true,
},
},
components: {
'code-cell': CodeCell,
'html-output': Html,
'image-output': Image,
},
data() {
return {
outputType: '',
};
},
computed: {
componentName() {
if (this.output.text) {
return 'code-cell';
} else if (this.output.data['image/png']) {
this.outputType = 'image/png';
return 'image-output';
} else if (this.output.data['text/html']) {
this.outputType = 'text/html';
return 'html-output';
} else if (this.output.data['image/svg+xml']) {
this.outputType = 'image/svg+xml';
return 'html-output';
}
this.outputType = 'text/plain';
return 'code-cell';
},
rawCode() {
if (this.output.text) {
return this.output.text.join('');
}
return this.dataForType(this.outputType);
},
},
methods: {
dataForType(type) {
let data = this.output.data[type];
if (typeof data === 'object') {
data = data.join('');
}
return data;
},
},
};
</script>
<template>
<div class="prompt">
<span v-if="type && count">
{{ type }} [{{ count }}]:
</span>
</div>
</template>
<script>
export default {
props: {
type: {
type: String,
required: false,
},
count: {
type: Number,
required: false,
},
},
};
</script>
<style scoped>
.prompt {
padding: 0 10px;
min-width: 7em;
font-family: monospace;
}
</style>
<template>
<div v-if="hasNotebook">
<component
v-for="(cell, index) in cells"
:is="cellType(cell.cell_type)"
:cell="cell"
:key="index"
:code-css-class="codeCssClass" />
</div>
</template>
<script>
import {
MarkdownCell,
CodeCell,
} from './cells';
export default {
components: {
'code-cell': CodeCell,
'markdown-cell': MarkdownCell,
},
props: {
notebook: {
type: Object,
required: true,
},
codeCssClass: {
type: String,
required: false,
default: '',
},
},
methods: {
cellType(type) {
return `${type}-cell`;
},
},
computed: {
cells() {
if (this.notebook.worksheets) {
const data = {
cells: [],
};
return this.notebook.worksheets.reduce((cellData, sheet) => {
const cellDataCopy = cellData;
cellDataCopy.cells = cellDataCopy.cells.concat(sheet.cells);
return cellDataCopy;
}, data).cells;
}
return this.notebook.cells;
},
hasNotebook() {
return Object.keys(this.notebook).length;
},
},
};
</script>
<style>
.cell,
.input,
.output {
display: flex;
width: 100%;
margin-bottom: 10px;
}
.cell pre {
margin: 0;
width: 100%;
}
</style>
import Prism from 'prismjs';
import 'prismjs/components/prism-python';
import 'prismjs/plugins/custom-class/prism-custom-class';
Prism.plugins.customClass.map({
comment: 'c',
error: 'err',
operator: 'o',
constant: 'kc',
namespace: 'kn',
keyword: 'k',
string: 's',
number: 'm',
'attr-name': 'na',
builtin: 'nb',
entity: 'ni',
function: 'nf',
tag: 'nt',
variable: 'nv',
});
export default Prism;
...@@ -33,6 +33,7 @@ ...@@ -33,6 +33,7 @@
var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove; var $block, $collapsedSidebar, $dropdown, $loading, $selectbox, $value, abilityName, assignTo, assigneeTemplate, collapsedAssigneeTemplate, defaultLabel, firstUser, issueURL, selectedId, showAnyUser, showNullUser, showMenuAbove;
$dropdown = $(dropdown); $dropdown = $(dropdown);
options.projectId = $dropdown.data('project-id'); options.projectId = $dropdown.data('project-id');
options.groupId = $dropdown.data('group-id');
options.showCurrentUser = $dropdown.data('current-user'); options.showCurrentUser = $dropdown.data('current-user');
options.todoFilter = $dropdown.data('todo-filter'); options.todoFilter = $dropdown.data('todo-filter');
options.todoStateFilter = $dropdown.data('todo-state-filter'); options.todoStateFilter = $dropdown.data('todo-state-filter');
......
...@@ -108,8 +108,7 @@ ...@@ -108,8 +108,7 @@
} }
.award-control { .award-control {
margin: 3px 5px 3px 0; margin-right: 5px;
padding: .35em .4em;
outline: 0; outline: 0;
&.disabled { &.disabled {
......
...@@ -70,7 +70,7 @@ pre { ...@@ -70,7 +70,7 @@ pre {
} }
hr { hr {
margin: $gl-padding 0; margin: 24px 0;
border-top: 1px solid darken($gray-normal, 8%); border-top: 1px solid darken($gray-normal, 8%);
} }
......
...@@ -73,14 +73,6 @@ ...@@ -73,14 +73,6 @@
&.wiki { &.wiki {
padding: 30px $gl-padding; padding: 30px $gl-padding;
.highlight {
margin-bottom: 9px;
> pre {
margin: 0;
}
}
} }
&.blob-no-preview { &.blob-no-preview {
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
padding: 0; padding: 0;
.timeline-entry { .timeline-entry {
padding: $gl-padding $gl-btn-padding 14px; padding: $gl-padding $gl-btn-padding 0;
border-color: $white-normal; border-color: $white-normal;
color: $gl-text-color; color: $gl-text-color;
border-bottom: 1px solid $border-white-light; border-bottom: 1px solid $border-white-light;
......
...@@ -8,6 +8,13 @@ ...@@ -8,6 +8,13 @@
img { img {
max-width: 100%; max-width: 100%;
margin: 0 0 8px;
}
p a:not(.no-attachment-icon) img {
// Remove bottom padding because
// <p> already has $gl-padding bottom
margin-bottom: 0;
} }
*:first-child:not(.katex-display) { *:first-child:not(.katex-display) {
...@@ -47,44 +54,50 @@ ...@@ -47,44 +54,50 @@
h1 { h1 {
font-size: 1.75em; font-size: 1.75em;
font-weight: 600; font-weight: 600;
margin: 16px 0 10px; margin: 24px 0 16px;
padding: 0 0 0.3em; padding-bottom: 0.3em;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
color: $gl-text-color; color: $gl-text-color;
&:first-child {
margin-top: 0;
}
} }
h2 { h2 {
font-size: 1.5em; font-size: 1.5em;
font-weight: 600; font-weight: 600;
margin: 16px 0 10px; margin: 24px 0 16px;
padding-bottom: 0.3em;
border-bottom: 1px solid $white-dark;
color: $gl-text-color; color: $gl-text-color;
} }
h3 { h3 {
margin: 16px 0 10px; margin: 24px 0 16px;
font-size: 1.3em; font-size: 1.3em;
} }
h4 { h4 {
margin: 16px 0 10px; margin: 24px 0 16px;
font-size: 1.2em; font-size: 1.2em;
} }
h5 { h5 {
margin: 16px 0 10px; margin: 24px 0 16px;
font-size: 1em; font-size: 1em;
} }
h6 { h6 {
margin: 16px 0 10px; margin: 24px 0 16px;
font-size: 0.95em; font-size: 0.95em;
} }
blockquote { blockquote {
color: $gl-grayish-blue; color: $gl-grayish-blue;
font-size: inherit; font-size: inherit;
padding: 8px 21px; padding: 8px 24px;
margin: 12px 0; margin: 16px 0;
border-left: 3px solid $white-dark; border-left: 3px solid $white-dark;
} }
...@@ -95,19 +108,20 @@ ...@@ -95,19 +108,20 @@
blockquote p { blockquote p {
color: $gl-grayish-blue !important; color: $gl-grayish-blue !important;
margin: 0;
font-size: inherit; font-size: inherit;
line-height: 1.5; line-height: 1.5;
} }
p { p {
color: $gl-text-color; color: $gl-text-color;
margin: 6px 0 0; margin: 0 0 16px;
} }
table { table {
@extend .table; @extend .table;
@extend .table-bordered; @extend .table-bordered;
margin: 12px 0; margin: 16px 0;
color: $gl-text-color; color: $gl-text-color;
th { th {
...@@ -120,7 +134,7 @@ ...@@ -120,7 +134,7 @@
} }
pre { pre {
margin: 12px 0; margin-bottom: 16px;
font-size: 13px; font-size: 13px;
line-height: 1.6em; line-height: 1.6em;
overflow-x: auto; overflow-x: auto;
...@@ -134,7 +148,7 @@ ...@@ -134,7 +148,7 @@
ul, ul,
ol { ol {
padding: 0; padding: 0;
margin: 3px 0 !important; margin: 0 0 16px !important;
} }
ul:dir(rtl), ul:dir(rtl),
......
...@@ -29,11 +29,5 @@ ...@@ -29,11 +29,5 @@
.description { .description {
margin-top: 6px; margin-top: 6px;
p {
&:last-child {
margin-bottom: 0;
}
}
} }
} }
...@@ -52,7 +52,7 @@ ...@@ -52,7 +52,7 @@
.title { .title {
padding: 0; padding: 0;
margin: 0; margin-bottom: 16px;
border-bottom: none; border-bottom: none;
} }
...@@ -357,6 +357,8 @@ ...@@ -357,6 +357,8 @@
} }
.detail-page-description { .detail-page-description {
padding: 16px 0 0;
small { small {
color: $gray-darkest; color: $gray-darkest;
} }
...@@ -364,6 +366,8 @@ ...@@ -364,6 +366,8 @@
.edited-text { .edited-text {
color: $gray-darkest; color: $gray-darkest;
display: block;
margin: 0 0 16px;
.author_link { .author_link {
color: $gray-darkest; color: $gray-darkest;
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
.note-edit-form { .note-edit-form {
.note-form-actions { .note-form-actions {
position: relative; position: relative;
margin-top: $gl-padding; margin: $gl-padding 0;
} }
.note-preview-holder { .note-preview-holder {
...@@ -387,6 +387,7 @@ ...@@ -387,6 +387,7 @@
@media (max-width: $screen-xs-max) { @media (max-width: $screen-xs-max) {
display: flex; display: flex;
width: 100%; width: 100%;
margin-bottom: 10px;
.comment-btn { .comment-btn {
flex-grow: 1; flex-grow: 1;
......
...@@ -102,13 +102,12 @@ ul.notes { ...@@ -102,13 +102,12 @@ ul.notes {
.note-awards { .note-awards {
.js-awards-block { .js-awards-block {
padding: 2px; margin-bottom: 16px;
margin-top: 10px;
} }
} }
.note-header { .note-header {
padding-bottom: 3px; padding-bottom: 8px;
padding-right: 20px; padding-right: 20px;
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
...@@ -151,6 +150,10 @@ ul.notes { ...@@ -151,6 +150,10 @@ ul.notes {
margin-left: 65px; margin-left: 65px;
} }
.note-header {
padding-bottom: 0;
}
&.timeline-entry::after { &.timeline-entry::after {
clear: none; clear: none;
} }
...@@ -386,6 +389,10 @@ ul.notes { ...@@ -386,6 +389,10 @@ ul.notes {
.note-headline-meta { .note-headline-meta {
display: inline-block; display: inline-block;
white-space: nowrap; white-space: nowrap;
.system-note-message {
white-space: normal;
}
} }
/** /**
......
...@@ -28,7 +28,7 @@ class Admin::GroupsController < Admin::ApplicationController ...@@ -28,7 +28,7 @@ class Admin::GroupsController < Admin::ApplicationController
if @group.save if @group.save
@group.add_owner(current_user) @group.add_owner(current_user)
redirect_to [:admin, @group], notice: 'Group was successfully created.' redirect_to [:admin, @group], notice: "Group '#{@group.name}' was successfully created."
else else
render "new" render "new"
end end
......
module RendersBlob
extend ActiveSupport::Concern
def render_blob_json(blob)
viewer =
if params[:viewer] == 'rich'
blob.rich_viewer
else
blob.simple_viewer
end
return render_404 unless viewer
render json: {
html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false)
}
end
end
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
class Projects::BlobController < Projects::ApplicationController class Projects::BlobController < Projects::ApplicationController
include ExtractsPath include ExtractsPath
include CreatesCommit include CreatesCommit
include RendersBlob
include ActionView::Helpers::SanitizeHelper include ActionView::Helpers::SanitizeHelper
# Raised when given an invalid file path # Raised when given an invalid file path
...@@ -34,8 +35,20 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -34,8 +35,20 @@ class Projects::BlobController < Projects::ApplicationController
end end
def show def show
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } @blob.override_max_size! if params[:override_max_size] == 'true'
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
respond_to do |format|
format.html do
environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit }
@environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last
render 'show'
end
format.json do
render_blob_json(@blob)
end
end
end end
def edit def edit
...@@ -96,7 +109,7 @@ class Projects::BlobController < Projects::ApplicationController ...@@ -96,7 +109,7 @@ class Projects::BlobController < Projects::ApplicationController
private private
def blob def blob
@blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path)) @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path), @project)
if @blob if @blob
@blob @blob
......
...@@ -23,6 +23,7 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -23,6 +23,7 @@ class Projects::MilestonesController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
@project_namespace = @project.namespace.becomes(Namespace)
@milestones = @milestones.includes(:project) @milestones = @milestones.includes(:project)
@milestones = @milestones.page(params[:page]) @milestones = @milestones.page(params[:page])
end end
......
...@@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController ...@@ -15,7 +15,7 @@ class Projects::RawController < Projects::ApplicationController
return if cached_blob? return if cached_blob?
if @blob.lfs_pointer? && project.lfs_enabled? if @blob.valid_lfs_pointer?
send_lfs_object send_lfs_object
else else
send_git_blob @repository, @blob send_git_blob @repository, @blob
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
# current_user - which user use # current_user - which user use
# params: # params:
# scope: 'created-by-me' or 'assigned-to-me' or 'all' # scope: 'created-by-me' or 'assigned-to-me' or 'all'
# state: 'open' or 'closed' or 'all' # state: 'open', 'closed', 'merged', or 'all'
# group_id: integer # group_id: integer
# project_id: integer # project_id: integer
# milestone_title: string # milestone_title: string
......
...@@ -52,7 +52,7 @@ module BlobHelper ...@@ -52,7 +52,7 @@ module BlobHelper
if !on_top_of_branch?(project, ref) if !on_top_of_branch?(project, ref)
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' }
elsif blob.lfs_pointer? elsif blob.valid_lfs_pointer?
button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' }
elsif can_modify_blob?(blob, project, ref) elsif can_modify_blob?(blob, project, ref)
button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal'
...@@ -95,7 +95,7 @@ module BlobHelper ...@@ -95,7 +95,7 @@ module BlobHelper
end end
def can_modify_blob?(blob, project = @project, ref = @ref) def can_modify_blob?(blob, project = @project, ref = @ref)
!blob.lfs_pointer? && can_edit_tree?(project, ref) !blob.valid_lfs_pointer? && can_edit_tree?(project, ref)
end end
def leave_edit_message def leave_edit_message
...@@ -118,28 +118,15 @@ module BlobHelper ...@@ -118,28 +118,15 @@ module BlobHelper
icon("#{file_type_icon_class('file', mode, name)} fw") icon("#{file_type_icon_class('file', mode, name)} fw")
end end
def blob_text_viewable?(blob) def blob_raw_url
blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? namespace_project_raw_path(@project.namespace, @project, @id)
end
def blob_rendered_as_text?(blob)
blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text'
end
def blob_size(blob)
if blob.lfs_pointer?
blob.lfs_size
else
blob.size
end
end end
# SVGs can contain malicious JavaScript; only include whitelisted # SVGs can contain malicious JavaScript; only include whitelisted
# elements and attributes. Note that this whitelist is by no means complete # elements and attributes. Note that this whitelist is by no means complete
# and may omit some elements. # and may omit some elements.
def sanitize_svg(blob) def sanitize_svg_data(data)
blob.data = Gitlab::Sanitizers::SVG.clean(blob.data) Gitlab::Sanitizers::SVG.clean(data)
blob
end end
# If we blindly set the 'real' content type when serving a Git blob we # If we blindly set the 'real' content type when serving a Git blob we
...@@ -221,13 +208,42 @@ module BlobHelper ...@@ -221,13 +208,42 @@ module BlobHelper
clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard')
end end
def copy_blob_content_button(blob) def copy_blob_source_button(blob)
return if markup?(blob.name) clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard")
clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard")
end end
def open_raw_file_button(path) def open_raw_file_button(path)
link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' }
end end
def blob_render_error_reason(viewer)
case viewer.render_error
when :too_large
max_size =
if viewer.absolutely_too_large?
viewer.absolute_max_size
elsif viewer.too_large?
viewer.max_size
end
"it is larger than #{number_to_human_size(max_size)}"
when :server_side_but_stored_in_lfs
"it is stored in LFS"
end
end
def blob_render_error_options(viewer)
options = []
if viewer.render_error == :too_large && viewer.can_override_max_size?
options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil)))
end
if viewer.rich? && viewer.blob.rendered_as_text?
options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' })
end
options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer')
options
end
end end
##
# DEPRECATED
#
# These helpers are deprecated in favor of detailed CI/CD statuses.
#
# See 'detailed_status?` method and `Gitlab::Ci::Status` module.
#
module CiStatusHelper module CiStatusHelper
def ci_status_path(pipeline) def ci_status_path(pipeline)
project = pipeline.project project = pipeline.project
namespace_project_pipeline_path(project.namespace, project, pipeline) namespace_project_pipeline_path(project.namespace, project, pipeline)
end end
# Is used by Commit and Merge Request Widget
def ci_label_for_status(status) def ci_label_for_status(status)
if detailed_status?(status) if detailed_status?(status)
return status.label return status.label
...@@ -22,6 +28,23 @@ module CiStatusHelper ...@@ -22,6 +28,23 @@ module CiStatusHelper
end end
end end
def ci_text_for_status(status)
if detailed_status?(status)
return status.text
end
case status
when 'success'
'passed'
when 'success_with_warnings'
'passed'
when 'manual'
'blocked'
else
status
end
end
def ci_status_for_statuseable(subject) def ci_status_for_statuseable(subject)
status = subject.try(:status) || 'not found' status = subject.try(:status) || 'not found'
status.humanize status.humanize
......
...@@ -7,6 +7,11 @@ module IconsHelper ...@@ -7,6 +7,11 @@ module IconsHelper
# font-awesome-rails gem, but should we ever use a different icon pack in the # font-awesome-rails gem, but should we ever use a different icon pack in the
# future we won't have to change hundreds of method calls. # future we won't have to change hundreds of method calls.
def icon(names, options = {}) def icon(names, options = {})
if (options.keys & %w[aria-hidden aria-label]).empty?
# Add `aria-hidden` if there are no aria's set
options['aria-hidden'] = true
end
options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options) options.include?(:base) ? fa_stacked_icon(names, options) : fa_icon(names, options)
end end
......
...@@ -56,11 +56,12 @@ module MergeRequestsHelper ...@@ -56,11 +56,12 @@ module MergeRequestsHelper
end end
def issues_sentence(issues) def issues_sentence(issues)
# Sorting based on the `#123` or `group/project#123` reference will sort # Issuable sorter will sort local issues, then issues from the same
# local issues first. # namespace, then all other issues.
issues.map do |issue| issues = Gitlab::IssuableSorter.sort(@project, issues).map do |issue|
issue.to_reference(@project) issue.to_reference(@project)
end.sort.to_sentence end
issues.to_sentence
end end
def mr_closes_issues def mr_closes_issues
......
...@@ -3,8 +3,40 @@ class Blob < SimpleDelegator ...@@ -3,8 +3,40 @@ class Blob < SimpleDelegator
CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute
CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour
# The maximum size of an SVG that can be displayed. MAXIMUM_TEXT_HIGHLIGHT_SIZE = 1.megabyte
MAXIMUM_SVG_SIZE = 2.megabytes
# Finding a viewer for a blob happens based only on extension and whether the
# blob is binary or text, which means 1 blob should only be matched by 1 viewer,
# and the order of these viewers doesn't really matter.
#
# However, when the blob is an LFS pointer, we cannot know for sure whether the
# file being pointed to is binary or text. In this case, we match only on
# extension, preferring binary viewers over text ones if both exist, since the
# large files referred to in "Large File Storage" are much more likely to be
# binary than text.
#
# `.stl` files, for example, exist in both binary and text forms, and are
# handled by different viewers (`BinarySTL` and `TextSTL`) depending on blob
# type. LFS pointers to `.stl` files are assumed to always be the binary kind,
# and use the `BinarySTL` viewer.
RICH_VIEWERS = [
BlobViewer::Markup,
BlobViewer::Notebook,
BlobViewer::SVG,
BlobViewer::Image,
BlobViewer::Sketch,
BlobViewer::PDF,
BlobViewer::BinarySTL,
BlobViewer::TextSTL,
].freeze
BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze
TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze
attr_reader :project
# Wrap a Gitlab::Git::Blob object, or return nil when given nil # Wrap a Gitlab::Git::Blob object, or return nil when given nil
# #
...@@ -16,10 +48,16 @@ class Blob < SimpleDelegator ...@@ -16,10 +48,16 @@ class Blob < SimpleDelegator
# #
# blob = Blob.decorate(nil) # blob = Blob.decorate(nil)
# puts "truthy" if blob # No output # puts "truthy" if blob # No output
def self.decorate(blob) def self.decorate(blob, project = nil)
return if blob.nil? return if blob.nil?
new(blob) new(blob, project)
end
def initialize(blob, project = nil)
@project = project
super(blob)
end end
# Returns the data of the blob. # Returns the data of the blob.
...@@ -35,82 +73,107 @@ class Blob < SimpleDelegator ...@@ -35,82 +73,107 @@ class Blob < SimpleDelegator
end end
def no_highlighting? def no_highlighting?
size && size > 1.megabyte size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE
end end
def only_display_raw? def too_large?
size && truncated? size && truncated?
end end
# Returns the size of the file that this blob represents. If this blob is an
# LFS pointer, this is the size of the file stored in LFS. Otherwise, this is
# the size of the blob itself.
def raw_size
if valid_lfs_pointer?
lfs_size
else
size
end
end
# Returns whether the file that this blob represents is binary. If this blob is
# an LFS pointer, we assume the file stored in LFS is binary, unless a
# text-based rich blob viewer matched on the file's extension. Otherwise, this
# depends on the type of the blob itself.
def raw_binary?
if valid_lfs_pointer?
if rich_viewer
rich_viewer.binary?
else
true
end
else
binary?
end
end
def extension def extension
extname.downcase.delete('.') @extension ||= extname.downcase.delete('.')
end end
def svg? def video?
text? && language && language.name == 'SVG' UploaderHelper::VIDEO_EXT.include?(extension)
end end
def pdf? def readable_text?
extension == 'pdf' text? && !valid_lfs_pointer? && !too_large?
end end
def ipython_notebook? def valid_lfs_pointer?
text? && language&.name == 'Jupyter Notebook' lfs_pointer? && project&.lfs_enabled?
end end
def sketch? def invalid_lfs_pointer?
binary? && extension == 'sketch' lfs_pointer? && !project&.lfs_enabled?
end end
def stl? def simple_viewer
extension == 'stl' @simple_viewer ||= simple_viewer_class.new(self)
end end
def markup? def rich_viewer
text? && Gitlab::MarkupHelper.markup?(name) return @rich_viewer if defined?(@rich_viewer)
@rich_viewer = rich_viewer_class&.new(self)
end end
def size_within_svg_limits? def rendered_as_text?(ignore_errors: true)
size <= MAXIMUM_SVG_SIZE simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?)
end end
def video? def show_viewer_switcher?
UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) rendered_as_text? && rich_viewer
end end
def to_partial_path(project) def override_max_size!
if lfs_pointer? simple_viewer&.override_max_size = true
if project.lfs_enabled? rich_viewer&.override_max_size = true
'download' end
else
'text' private
end
elsif image? def simple_viewer_class
'image' if empty?
elsif svg? BlobViewer::Empty
'svg' elsif raw_binary?
elsif pdf? BlobViewer::Download
'pdf' else # text
elsif ipython_notebook? BlobViewer::Text
'notebook'
elsif sketch?
'sketch'
elsif stl?
'stl'
elsif markup?
if only_display_raw?
'too_large'
else
'markup'
end
elsif text?
if only_display_raw?
'too_large'
else
'text'
end
else
'download'
end end
end end
def rich_viewer_class
return if invalid_lfs_pointer? || empty?
classes =
if valid_lfs_pointer?
BINARY_VIEWERS + TEXT_VIEWERS
elsif binary?
BINARY_VIEWERS
else # text
TEXT_VIEWERS
end
classes.find { |viewer_class| viewer_class.can_render?(self) }
end
end end
module BlobViewer
class Base
class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size
delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class
attr_reader :blob
attr_accessor :override_max_size
def initialize(blob)
@blob = blob
end
def self.partial_path
"projects/blob/viewers/#{partial_name}"
end
def self.rich?
type == :rich
end
def self.simple?
type == :simple
end
def self.client_side?
client_side
end
def self.server_side?
!client_side?
end
def self.binary?
binary
end
def self.text?
!binary?
end
def self.can_render?(blob)
!extensions || extensions.include?(blob.extension)
end
def too_large?
blob.raw_size > max_size
end
def absolutely_too_large?
blob.raw_size > absolute_max_size
end
def can_override_max_size?
too_large? && !absolutely_too_large?
end
# This method is used on the server side to check whether we can attempt to
# render the blob at all. Human-readable error messages are found in the
# `BlobHelper#blob_render_error_reason` helper.
#
# This method does not and should not load the entire blob contents into
# memory, and should not be overridden to do so in order to validate the
# format of the blob.
#
# Prefer to implement a client-side viewer, where the JS component loads the
# binary from `blob_raw_url` and does its own format validation and error
# rendering, especially for potentially large binary formats.
def render_error
return @render_error if defined?(@render_error)
@render_error =
if server_side_but_stored_in_lfs?
# Files stored in LFS can only be rendered using a client-side viewer,
# since we do not want to read large amounts of data into memory on the
# server side. Client-side viewers use JS and can fetch the file from
# `blob_raw_url` using AJAX.
:server_side_but_stored_in_lfs
elsif override_max_size ? absolutely_too_large? : too_large?
:too_large
end
end
def prepare!
if server_side? && blob.project
blob.load_all_data!(blob.project.repository)
end
end
private
def server_side_but_stored_in_lfs?
server_side? && blob.valid_lfs_pointer?
end
end
end
module BlobViewer
class BinarySTL < Base
include Rich
include ClientSide
self.partial_name = 'stl'
self.extensions = %w(stl)
self.binary = true
end
end
module BlobViewer
module ClientSide
extend ActiveSupport::Concern
included do
self.client_side = true
self.max_size = 10.megabytes
self.absolute_max_size = 50.megabytes
end
end
end
module BlobViewer
class Download < Base
include Simple
# We treat the Download viewer as if it renders the content client-side,
# so that it doesn't attempt to load the entire blob contents and is
# rendered synchronously instead of loaded asynchronously.
include ClientSide
self.partial_name = 'download'
self.binary = true
# We can always render the Download viewer, even if the blob is in LFS or too large.
def render_error
nil
end
end
end
module BlobViewer
class Empty < Base
include Simple
include ServerSide
self.partial_name = 'empty'
self.binary = true
end
end
module BlobViewer
class Image < Base
include Rich
include ClientSide
self.partial_name = 'image'
self.extensions = UploaderHelper::IMAGE_EXT
self.binary = true
self.switcher_icon = 'picture-o'
self.switcher_title = 'image'
end
end
module BlobViewer
class Markup < Base
include Rich
include ServerSide
self.partial_name = 'markup'
self.extensions = Gitlab::MarkupHelper::EXTENSIONS
self.binary = false
end
end
module BlobViewer
class Notebook < Base
include Rich
include ClientSide
self.partial_name = 'notebook'
self.extensions = %w(ipynb)
self.binary = false
self.switcher_icon = 'file-text-o'
self.switcher_title = 'notebook'
end
end
module BlobViewer
class PDF < Base
include Rich
include ClientSide
self.partial_name = 'pdf'
self.extensions = %w(pdf)
self.binary = true
self.switcher_icon = 'file-pdf-o'
self.switcher_title = 'PDF'
end
end
module BlobViewer
module Rich
extend ActiveSupport::Concern
included do
self.type = :rich
self.switcher_icon = 'file-text-o'
self.switcher_title = 'rendered file'
end
end
end
module BlobViewer
module ServerSide
extend ActiveSupport::Concern
included do
self.client_side = false
self.max_size = 2.megabytes
self.absolute_max_size = 5.megabytes
end
end
end
module BlobViewer
module Simple
extend ActiveSupport::Concern
included do
self.type = :simple
self.switcher_icon = 'code'
self.switcher_title = 'source'
end
end
end
module BlobViewer
class Sketch < Base
include Rich
include ClientSide
self.partial_name = 'sketch'
self.extensions = %w(sketch)
self.binary = true
self.switcher_icon = 'file-image-o'
self.switcher_title = 'preview'
end
end
module BlobViewer
class SVG < Base
include Rich
include ServerSide
self.partial_name = 'svg'
self.extensions = %w(svg)
self.binary = false
self.switcher_icon = 'picture-o'
self.switcher_title = 'image'
end
end
module BlobViewer
class Text < Base
include Simple
include ServerSide
self.partial_name = 'text'
self.binary = false
self.max_size = 1.megabyte
self.absolute_max_size = 10.megabytes
end
end
module BlobViewer
class TextSTL < BinarySTL
self.binary = false
end
end
...@@ -75,29 +75,32 @@ module Ci ...@@ -75,29 +75,32 @@ module Ci
pipeline.update_duration pipeline.update_duration
end end
before_transition any => [:manual] do |pipeline|
pipeline.update_duration
end
before_transition canceled: any - [:canceled] do |pipeline| before_transition canceled: any - [:canceled] do |pipeline|
pipeline.auto_canceled_by = nil pipeline.auto_canceled_by = nil
end end
after_transition [:created, :pending] => :running do |pipeline| after_transition [:created, :pending] => :running do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end end
after_transition any => [:success] do |pipeline| after_transition any => [:success] do |pipeline|
pipeline.run_after_commit { PipelineMetricsWorker.perform_async(id) } pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) }
end end
after_transition [:created, :pending, :running] => :success do |pipeline| after_transition [:created, :pending, :running] => :success do |pipeline|
pipeline.run_after_commit { PipelineSuccessWorker.perform_async(id) } pipeline.run_after_commit { PipelineSuccessWorker.perform_async(pipeline.id) }
end end
after_transition do |pipeline, transition| after_transition do |pipeline, transition|
next if transition.loopback? next if transition.loopback?
pipeline.run_after_commit do pipeline.run_after_commit do
PipelineHooksWorker.perform_async(id) PipelineHooksWorker.perform_async(pipeline.id)
Ci::ExpirePipelineCacheService.new(project, nil) ExpirePipelineCacheWorker.perform_async(pipeline.id)
.execute(pipeline)
end end
end end
...@@ -385,6 +388,11 @@ module Ci ...@@ -385,6 +388,11 @@ module Ci
.select { |merge_request| merge_request.head_pipeline.try(:id) == self.id } .select { |merge_request| merge_request.head_pipeline.try(:id) == self.id }
end end
# All the merge requests for which the current pipeline runs/ran against
def all_merge_requests
@all_merge_requests ||= project.merge_requests.where(source_branch: ref)
end
def detailed_status(current_user) def detailed_status(current_user)
Gitlab::Ci::Status::Pipeline::Factory Gitlab::Ci::Status::Pipeline::Factory
.new(self, current_user) .new(self, current_user)
......
...@@ -316,7 +316,7 @@ class Commit ...@@ -316,7 +316,7 @@ class Commit
def uri_type(path) def uri_type(path)
entry = @raw.tree.path(path) entry = @raw.tree.path(path)
if entry[:type] == :blob if entry[:type] == :blob
blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name])) blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project)
blob.image? || blob.video? ? :raw : :blob blob.image? || blob.video? ? :raw : :blob
else else
entry[:type] entry[:type]
......
...@@ -163,7 +163,20 @@ module Routable ...@@ -163,7 +163,20 @@ module Routable
end end
end end
# Every time `project.namespace.becomes(Namespace)` is called for polymorphic_path,
# a new instance is instantiated, and we end up duplicating the same query to retrieve
# the route. Caching this per request ensures that even if we have multiple instances,
# we will not have to duplicate work, avoiding N+1 queries in some cases.
def full_path def full_path
return uncached_full_path unless RequestStore.active?
key = "routable/full_path/#{self.class.name}/#{self.id}"
RequestStore[key] ||= uncached_full_path
end
private
def uncached_full_path
if route && route.path.present? if route && route.path.present?
@full_path ||= route.path @full_path ||= route.path
else else
...@@ -173,8 +186,6 @@ module Routable ...@@ -173,8 +186,6 @@ module Routable
end end
end end
private
def full_name_changed? def full_name_changed?
name_changed? || parent_changed? name_changed? || parent_changed?
end end
......
...@@ -10,4 +10,8 @@ class IndividualNoteDiscussion < Discussion ...@@ -10,4 +10,8 @@ class IndividualNoteDiscussion < Discussion
def individual_note? def individual_note?
true true
end end
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
end end
...@@ -198,22 +198,23 @@ class MergeRequest < ActiveRecord::Base ...@@ -198,22 +198,23 @@ class MergeRequest < ActiveRecord::Base
merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args) merge_request_diff ? merge_request_diff.raw_diffs(*args) : compare.raw_diffs(*args)
end end
def diffs(diff_options = nil) def diffs(diff_options = {})
if compare if compare
compare.diffs(diff_options) # When saving MR diffs, `no_collapse` is implicitly added (because we need
# to save the entire contents to the DB), so add that here for
# consistency.
compare.diffs(diff_options.merge(no_collapse: true))
else else
merge_request_diff.diffs(diff_options) merge_request_diff.diffs(diff_options)
end end
end end
def diff_size def diff_size
# The `#diffs` method ends up at an instance of a class inheriting from # Calling `merge_request_diff.diffs.real_size` will also perform
# `Gitlab::Diff::FileCollection::Base`, so use those options as defaults # highlighting, which we don't need here.
# here too, to get the same diff size without performing highlighting. return real_size if merge_request_diff
#
opts = Gitlab::Diff::FileCollection::Base.default_options.merge(diff_options || {})
raw_diffs(opts).size diffs.real_size
end end
def diff_base_commit def diff_base_commit
......
...@@ -260,7 +260,7 @@ class MergeRequestDiff < ActiveRecord::Base ...@@ -260,7 +260,7 @@ class MergeRequestDiff < ActiveRecord::Base
new_attributes[:state] = :empty new_attributes[:state] = :empty
else else
diff_collection = compare.diffs(Commit.max_diff_options) diff_collection = compare.diffs(Commit.max_diff_options)
new_attributes[:real_size] = compare.diffs.real_size new_attributes[:real_size] = diff_collection.real_size
if diff_collection.any? if diff_collection.any?
new_diffs = dump_diffs(diff_collection) new_diffs = dump_diffs(diff_collection)
......
...@@ -15,8 +15,12 @@ class OutOfContextDiscussion < Discussion ...@@ -15,8 +15,12 @@ class OutOfContextDiscussion < Discussion
def self.override_discussion_id(note) def self.override_discussion_id(note)
discussion_id(note) discussion_id(note)
end end
def self.note_class def self.note_class
Note Note
end end
def reply_attributes
super.tap { |attrs| attrs.delete(:discussion_id) }
end
end end
...@@ -457,7 +457,7 @@ class Repository ...@@ -457,7 +457,7 @@ class Repository
def blob_at(sha, path) def blob_at(sha, path)
unless Gitlab::Git.blank_ref?(sha) unless Gitlab::Git.blank_ref?(sha)
Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project)
end end
rescue Gitlab::Git::Repository::NoRepository rescue Gitlab::Git::Repository::NoRepository
nil nil
......
...@@ -4,21 +4,14 @@ class ProjectPolicy < BasePolicy ...@@ -4,21 +4,14 @@ class ProjectPolicy < BasePolicy
def rules def rules
team_access!(user) team_access!(user)
owner = project.owner == user || owner_access! if user.admin? || owner?
(project.group && project.group.has_owner?(user))
owner_access! if user.admin? || owner
auditor_access! if user.auditor? auditor_access! if user.auditor?
team_member_owner_access! if owner team_member_owner_access! if owner?
if project.public? || (project.internal? && !user.external?) if project.public? || (project.internal? && !user.external?)
guest_access! guest_access!
public_access! public_access!
can! :request_access if access_requestable?
if project.request_access_enabled &&
!(owner || user.admin? || project.team.member?(user) || project_group_member?(user))
can! :request_access
end
end end
archived_access! if project.archived? archived_access! if project.archived?
...@@ -33,6 +26,13 @@ class ProjectPolicy < BasePolicy ...@@ -33,6 +26,13 @@ class ProjectPolicy < BasePolicy
@subject @subject
end end
def owner?
return @owner if defined?(@owner)
@owner = project.owner == user ||
(project.group && project.group.has_owner?(user))
end
def guest_access! def guest_access!
can! :read_project can! :read_project
can! :read_board can! :read_board
...@@ -251,14 +251,6 @@ class ProjectPolicy < BasePolicy ...@@ -251,14 +251,6 @@ class ProjectPolicy < BasePolicy
disabled_features! disabled_features!
end end
def project_group_member?(user)
project.group &&
(
project.group.members_with_parents.exists?(user_id: user.id) ||
project.group.requesters.exists?(user_id: user.id)
)
end
def block_issues_abilities def block_issues_abilities
unless project.feature_available?(:issues, user) unless project.feature_available?(:issues, user)
cannot! :read_issue if project.default_issues_tracker? cannot! :read_issue if project.default_issues_tracker?
...@@ -279,6 +271,22 @@ class ProjectPolicy < BasePolicy ...@@ -279,6 +271,22 @@ class ProjectPolicy < BasePolicy
private private
def project_group_member?(user)
project.group &&
(
project.group.members_with_parents.exists?(user_id: user.id) ||
project.group.requesters.exists?(user_id: user.id)
)
end
def access_requestable?
project.request_access_enabled &&
!owner? &&
!user.admin? &&
!project.team.member?(user) &&
!project_group_member?(user)
end
# A base set of abilities for read-only users, which # A base set of abilities for read-only users, which
# is then augmented as necessary for anonymous and other # is then augmented as necessary for anonymous and other
# read-only users. # read-only users.
......
module Ci
class ExpirePipelineCacheService < BaseService
attr_reader :pipeline
def execute(pipeline)
@pipeline = pipeline
store = Gitlab::EtagCaching::Store.new
store.touch(project_pipelines_path)
store.touch(commit_pipelines_path) if pipeline.commit
store.touch(new_merge_request_pipelines_path)
merge_requests_pipelines_paths.each { |path| store.touch(path) }
Gitlab::Cache::Ci::ProjectPipelineStatus.update_for_pipeline(@pipeline)
end
private
def project_pipelines_path
Gitlab::Routing.url_helpers.namespace_project_pipelines_path(
project.namespace,
project,
format: :json)
end
def commit_pipelines_path
Gitlab::Routing.url_helpers.pipelines_namespace_project_commit_path(
project.namespace,
project,
pipeline.commit.id,
format: :json)
end
def new_merge_request_pipelines_path
Gitlab::Routing.url_helpers.new_namespace_project_merge_request_path(
project.namespace,
project,
format: :json)
end
def merge_requests_pipelines_paths
pipeline.merge_requests.collect do |merge_request|
Gitlab::Routing.url_helpers.pipelines_namespace_project_merge_request_path(
project.namespace,
project,
merge_request,
format: :json)
end
end
end
end
...@@ -6,18 +6,16 @@ module Users ...@@ -6,18 +6,16 @@ module Users
@params = params.dup @params = params.dup
end end
def execute def execute(skip_authorization: false)
raise Gitlab::Access::AccessDeniedError unless can_create_user? raise Gitlab::Access::AccessDeniedError unless skip_authorization || can_create_user?
user = User.new(build_user_params) user_params = build_user_params(skip_authorization: skip_authorization)
user = User.new(user_params)
if current_user&.admin? if current_user&.admin?
if params[:reset_password] @reset_token = user.generate_reset_token if params[:reset_password]
user.generate_reset_token
params[:force_random_password] = true
end
if params[:force_random_password] if user_params[:force_random_password]
random_password = Devise.friendly_token.first(Devise.password_length.min) random_password = Devise.friendly_token.first(Devise.password_length.min)
user.password = user.password_confirmation = random_password user.password = user.password_confirmation = random_password
end end
...@@ -81,7 +79,7 @@ module Users ...@@ -81,7 +79,7 @@ module Users
] ]
end end
def build_user_params def build_user_params(skip_authorization:)
if current_user&.admin? if current_user&.admin?
user_params = params.slice(*admin_create_params) user_params = params.slice(*admin_create_params)
user_params[:created_by_id] = current_user&.id user_params[:created_by_id] = current_user&.id
...@@ -90,11 +88,20 @@ module Users ...@@ -90,11 +88,20 @@ module Users
user_params.merge!(force_random_password: true, password_expires_at: nil) user_params.merge!(force_random_password: true, password_expires_at: nil)
end end
else else
user_params = params.slice(*signup_params) allowed_signup_params = signup_params
user_params[:skip_confirmation] = !current_application_settings.send_user_confirmation_email allowed_signup_params << :skip_confirmation if skip_authorization
user_params = params.slice(*allowed_signup_params)
if user_params[:skip_confirmation].nil?
user_params[:skip_confirmation] = skip_user_confirmation_email_from_setting
end
end end
user_params user_params
end end
def skip_user_confirmation_email_from_setting
!current_application_settings.send_user_confirmation_email
end
end end
end end
...@@ -6,8 +6,8 @@ module Users ...@@ -6,8 +6,8 @@ module Users
@params = params.dup @params = params.dup
end end
def execute def execute(skip_authorization: false)
user = Users::BuildService.new(current_user, params).execute user = Users::BuildService.new(current_user, params).execute(skip_authorization: skip_authorization)
@reset_token = user.generate_reset_token if user.recently_sent_password_reset? @reset_token = user.generate_reset_token if user.recently_sent_password_reset?
......
...@@ -15,27 +15,39 @@ module Users ...@@ -15,27 +15,39 @@ module Users
end end
def execute def execute
# Block the user before moving records to prevent a data race. transition = user.block_transition
# 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 user.transaction do
# 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
# Reverse the user block if record migration fails
if !migrate_records && transition
transition.rollback
user.save!
end
end
user.reload
end
private
def migrate_records
user.transaction(requires_new: true) do
@ghost_user = User.ghost @ghost_user = User.ghost
migrate_issues migrate_issues
migrate_merge_requests migrate_merge_requests
migrate_notes migrate_notes
migrate_abuse_reports migrate_abuse_reports
migrate_award_emoji migrate_award_emojis
end end
user.reload
end end
private
def migrate_issues def migrate_issues
user.issues.update_all(author_id: ghost_user.id) user.issues.update_all(author_id: ghost_user.id)
end end
...@@ -52,7 +64,7 @@ module Users ...@@ -52,7 +64,7 @@ module Users
user.reported_abuse_reports.update_all(reporter_id: ghost_user.id) user.reported_abuse_reports.update_all(reporter_id: ghost_user.id)
end end
def migrate_award_emoji def migrate_award_emojis
user.award_emoji.update_all(user_id: ghost_user.id) user.award_emoji.update_all(user_id: ghost_user.id)
end end
end end
......
...@@ -9,7 +9,7 @@ ...@@ -9,7 +9,7 @@
.bs-callout.bs-callout-warning.clearfix .bs-callout.bs-callout-warning.clearfix
%p %p
User cohorts are only shown when the User cohorts are only shown when the
= link_to 'usage ping', help_page_path('user/admin_area/usage_statistics'), target: '_blank' = link_to 'usage ping', help_page_path('user/admin_area/settings/usage_statistics', anchor: 'usage-ping'), target: '_blank'
is enabled. To enable it and see user cohorts, is enabled. To enable it and see user cohorts,
visit visit
= succeed '.' do = succeed '.' do
......
...@@ -56,7 +56,7 @@ ...@@ -56,7 +56,7 @@
Snippets Snippets
- if project_nav_tab? :settings - if project_nav_tab? :settings
= nav_link(path: %w[projects#edit members#show integrations#show repository#show ci_cd#show pages#show]) do = nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do
= link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do = link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
%span %span
Settings Settings
......
...@@ -74,7 +74,7 @@ ...@@ -74,7 +74,7 @@
- else - else
%hr %hr
- blob = diff_file.blob - blob = diff_file.blob
- if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) - if blob && blob.readable_text?
%table.code.white %table.code.white
= render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true }
- else - else
......
- ref = local_assigns.fetch(:ref) - ref = local_assigns.fetch(:ref)
- status = commit.status(ref) - status = commit.status(ref)
- if status - if status
= link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do = link_to pipelines_namespace_project_commit_path(commit.project.namespace, commit.project, commit), class: "ci-status ci-#{status}" do
= ci_icon_for_status(status) = ci_icon_for_status(status)
= ci_label_for_status(status) = ci_text_for_status(status)
= link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id"
= link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message"
......
...@@ -27,9 +27,4 @@ ...@@ -27,9 +27,4 @@
%article.file-holder %article.file-holder
= render "projects/blob/header", blob: blob = render "projects/blob/header", blob: blob
- if blob.empty? = render 'projects/blob/content', blob: blob
.file-content.code
.nothing-here-block
Empty file
- else
= render blob.to_partial_path(@project), blob: blob
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
- rich_viewer_active = rich_viewer && params[:viewer] != 'simple'
= render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active
- if rich_viewer
= render 'projects/blob/viewer', viewer: rich_viewer, hidden: !rich_viewer_active
...@@ -9,17 +9,19 @@ ...@@ -9,17 +9,19 @@
= copy_file_path_button(blob.path) = copy_file_path_button(blob.path)
%small %small
= number_to_human_size(blob_size(blob)) = number_to_human_size(blob.raw_size)
.file-actions.hidden-xs .file-actions.hidden-xs
= render 'projects/blob/viewer_switcher', blob: blob unless blame
.btn-group{ role: "group" }< .btn-group{ role: "group" }<
= copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob) = copy_blob_source_button(blob) if !blame && blob.rendered_as_text?(ignore_errors: false)
= open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id))
= view_on_environment_button(@commit.sha, @path, @environment) if @environment = view_on_environment_button(@commit.sha, @path, @environment) if @environment
.btn-group{ role: "group" }< .btn-group{ role: "group" }<
-# only show normal/blame view links for text files -# only show normal/blame view links for text files
- if blob_text_viewable?(blob) - if blob.readable_text?
- if blame - if blame
= link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id),
class: 'btn btn-sm' class: 'btn btn-sm'
...@@ -36,7 +38,8 @@ ...@@ -36,7 +38,8 @@
.btn-group{ role: "group" }< .btn-group{ role: "group" }<
- if current_user - if current_user
= lock_file_link(html_options: {class: 'btn btn-sm path-lock'}) = lock_file_link(html_options: {class: 'btn btn-sm path-lock'})
= edit_blob_link if blob_text_viewable?(blob)
= edit_blob_link if blob.readable_text?
- if current_user - if current_user
= replace_blob_link = replace_blob_link
= delete_blob_link = delete_blob_link
......
.file-content.image_file
%img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name }
.file-content.code
.nothing-here-block
The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}.
You can
= blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe
instead.
- if blob.size_within_svg_limits?
-# We need to scrub SVG but we cannot do so in the RawController: it would
-# be wrong/strange if RawController modified the data.
- blob.load_all_data!(@repository)
- blob = sanitize_svg(blob)
.file-content.image_file
%img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name }
- else
= render 'too_large'
- blob.load_all_data!(@repository)
= render 'shared/file_highlight', blob: blob, repository: @repository
.file-content.code
.nothing-here-block
The file could not be displayed as it is too large, you can
#{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')}
instead.
- hidden = local_assigns.fetch(:hidden, false)
- render_error = viewer.render_error
- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil?
- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously
.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) }
- if load_asynchronously
.text-center.prepend-top-default.append-bottom-default
= icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content')
- elsif render_error
= render 'projects/blob/render_error', viewer: viewer
- else
- viewer.prepare!
= render viewer.partial_path, viewer: viewer
- if blob.show_viewer_switcher?
- simple_viewer = blob.simple_viewer
- rich_viewer = blob.rich_viewer
.btn-group.js-blob-viewer-switcher{ role: "group" }
- simple_label = "Display #{simple_viewer.switcher_title}"
%button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }>
= icon(simple_viewer.switcher_icon)
- rich_label = "Display #{rich_viewer.switcher_title}"
%button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }>
= icon(rich_viewer.switcher_icon)
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
- page_title @blob.path, @ref - page_title @blob.path, @ref
= render "projects/commits/head" = render "projects/commits/head"
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('blob')
%div{ class: container_class } %div{ class: container_class }
= render 'projects/last_push' = render 'projects/last_push'
......
.file-content.blob_file.blob-no-preview .file-content.blob_file.blob-no-preview
.center .center
= link_to namespace_project_raw_path(@project.namespace, @project, @id) do = link_to blob_raw_url do
%h1.light %h1.light
%i.fa.fa-download = icon('download')
%h4 %h4
Download (#{number_to_human_size blob_size(blob)}) Download (#{number_to_human_size(viewer.blob.raw_size)})
.file-content.code
.nothing-here-block
Empty file
.file-content.image_file
%img{ src: blob_raw_url, alt: viewer.blob.name }
- blob = viewer.blob
.file-content.wiki
= render_markup(blob.name, blob.data)
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
= page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('notebook_viewer') = page_specific_javascript_bundle_tag('notebook_viewer')
.file-content#js-notebook-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } .file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } }
...@@ -2,4 +2,4 @@ ...@@ -2,4 +2,4 @@
= page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('pdf_viewer') = page_specific_javascript_bundle_tag('pdf_viewer')
.file-content#js-pdf-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } .file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } }
...@@ -2,6 +2,6 @@ ...@@ -2,6 +2,6 @@
= page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('sketch_viewer') = page_specific_javascript_bundle_tag('sketch_viewer')
.file-content#js-sketch-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } .file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } }
.js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' }
= icon('spinner spin 2x', 'aria-hidden' => 'true'); = icon('spinner spin 2x', 'aria-hidden' => 'true');
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment