Commit abd456ca authored by antonyliu's avatar antonyliu

Merge branch master

parents c8a1395f 54dbaba5
......@@ -400,7 +400,8 @@ gem 'html2text'
gem 'ruby-prof', '~> 0.17.0'
gem 'rbtrace', '~> 0.4', require: false
gem 'memory_profiler', require: false
gem 'memory_profiler', '~> 0.9', require: false
gem 'benchmark-memory', '~> 0.1', require: false
# OAuth
gem 'oauth2', '~> 1.4'
......
......@@ -82,6 +82,8 @@ GEM
bcrypt (3.1.12)
bcrypt_pbkdf (1.0.0)
benchmark-ips (2.3.0)
benchmark-memory (0.1.2)
memory_profiler (~> 0.9)
better_errors (2.5.0)
coderay (>= 1.0.0)
erubi (>= 1.0.0)
......@@ -1024,6 +1026,7 @@ DEPENDENCIES
batch-loader (~> 1.4.0)
bcrypt_pbkdf (~> 1.0)
benchmark-ips (~> 2.3.0)
benchmark-memory (~> 0.1)
better_errors (~> 2.5.0)
binding_of_caller (~> 0.8.0)
bootsnap (~> 1.4)
......@@ -1122,7 +1125,7 @@ DEPENDENCIES
lograge (~> 0.5)
loofah (~> 2.2)
mail_room (~> 0.9.1)
memory_profiler
memory_profiler (~> 0.9)
method_source (~> 0.8)
mimemagic (~> 0.3.2)
mini_magick
......
import { __ } from '~/locale';
const notImplemented = () => {
throw new Error('Not implemented!');
throw new Error(__('Not implemented!'));
};
export default {
......
import * as mutationTypes from './mutation_types';
import { __ } from '~/locale';
const notImplemented = () => {
throw new Error('Not implemented!');
throw new Error(__('Not implemented!'));
};
export default {
......
import Visibility from 'visibilityjs';
import Vue from 'vue';
import AccessorUtilities from '~/lib/utils/accessor';
import { GlToast } from '@gitlab/ui';
import PersistentUserCallout from '../persistent_user_callout';
import { s__, sprintf } from '../locale';
......@@ -43,8 +44,10 @@ export default class Clusters {
helpPath,
ingressHelpPath,
ingressDnsHelpPath,
clusterId,
} = document.querySelector('.js-edit-cluster-form').dataset;
this.clusterId = clusterId;
this.store = new ClustersStore();
this.store.setHelpPaths(helpPath, ingressHelpPath, ingressDnsHelpPath);
this.store.setManagePrometheusPath(managePrometheusPath);
......@@ -69,6 +72,10 @@ export default class Clusters {
this.errorContainer = document.querySelector('.js-cluster-error');
this.successContainer = document.querySelector('.js-cluster-success');
this.creatingContainer = document.querySelector('.js-cluster-creating');
this.unreachableContainer = document.querySelector('.js-cluster-api-unreachable');
this.authenticationFailureContainer = document.querySelector(
'.js-cluster-authentication-failure',
);
this.errorReasonContainer = this.errorContainer.querySelector('.js-error-reason');
this.successApplicationContainer = document.querySelector('.js-cluster-application-notice');
this.showTokenButton = document.querySelector('.js-show-cluster-token');
......@@ -125,6 +132,13 @@ export default class Clusters {
PersistentUserCallout.factory(callout);
}
addBannerCloseHandler(el, status) {
el.querySelector('.js-close-banner').addEventListener('click', () => {
el.classList.add('hidden');
this.setBannerDismissedState(status, true);
});
}
addListeners() {
if (this.showTokenButton) this.showTokenButton.addEventListener('click', this.showToken);
eventHub.$on('installApplication', this.installApplication);
......@@ -133,6 +147,9 @@ export default class Clusters {
eventHub.$on('saveKnativeDomain', data => this.saveKnativeDomain(data));
eventHub.$on('setKnativeHostname', data => this.setKnativeHostname(data));
eventHub.$on('uninstallApplication', data => this.uninstallApplication(data));
// Add event listener to all the banner close buttons
this.addBannerCloseHandler(this.unreachableContainer, 'unreachable');
this.addBannerCloseHandler(this.authenticationFailureContainer, 'authentication_failure');
}
removeListeners() {
......@@ -205,6 +222,8 @@ export default class Clusters {
this.errorContainer.classList.add('hidden');
this.successContainer.classList.add('hidden');
this.creatingContainer.classList.add('hidden');
this.unreachableContainer.classList.add('hidden');
this.authenticationFailureContainer.classList.add('hidden');
}
checkForNewInstalls(prevApplicationMap, newApplicationMap) {
......@@ -228,9 +247,32 @@ export default class Clusters {
}
}
setBannerDismissedState(status, isDismissed) {
if (AccessorUtilities.isLocalStorageAccessSafe()) {
window.localStorage.setItem(
`cluster_${this.clusterId}_banner_dismissed`,
`${status}_${isDismissed}`,
);
}
}
isBannerDismissed(status) {
let bannerState;
if (AccessorUtilities.isLocalStorageAccessSafe()) {
bannerState = window.localStorage.getItem(`cluster_${this.clusterId}_banner_dismissed`);
}
return bannerState === `${status}_true`;
}
updateContainer(prevStatus, status, error) {
this.hideAll();
if (this.isBannerDismissed(status)) {
return;
}
this.setBannerDismissedState(status, false);
// We poll all the time but only want the `created` banner to show when newly created
if (this.store.state.status !== 'created' || prevStatus !== this.store.state.status) {
switch (status) {
......@@ -241,6 +283,12 @@ export default class Clusters {
this.errorContainer.classList.remove('hidden');
this.errorReasonContainer.textContent = error;
break;
case 'unreachable':
this.unreachableContainer.classList.remove('hidden');
break;
case 'authentication_failure':
this.authenticationFailureContainer.classList.remove('hidden');
break;
case 'scheduled':
case 'creating':
this.creatingContainer.classList.remove('hidden');
......
......@@ -6,6 +6,7 @@ import 'core-js/fn/array/from';
import 'core-js/fn/array/includes';
import 'core-js/fn/object/assign';
import 'core-js/fn/object/values';
import 'core-js/fn/object/entries';
import 'core-js/fn/promise';
import 'core-js/fn/promise/finally';
import 'core-js/fn/string/code-point-at';
......
import Vue from 'vue';
import ErrorTrackingSettings from './components/app.vue';
import createStore from './store';
import initSettingsPanels from '~/settings_panels';
export default () => {
initSettingsPanels();
const formContainerEl = document.querySelector('.js-error-tracking-form');
const {
dataset: { apiHost, enabled, project, token, listProjectsEndpoint, operationsSettingsEndpoint },
......
......@@ -702,6 +702,10 @@ GitLabDropdown = (function() {
}
html = document.createElement('li');
if (rowHidden) {
html.style.display = 'none';
}
if (data === 'divider' || data === 'separator') {
html.className = data;
return html;
......
......@@ -86,6 +86,7 @@ export default {
'isScrollTopDisabled',
'isScrolledToBottomBeforeReceivingTrace',
'hasError',
'selectedStage',
]),
...mapGetters([
'headerTime',
......@@ -121,7 +122,13 @@ export default {
// fetch the stages for the dropdown on the sidebar
job(newVal, oldVal) {
if (_.isEmpty(oldVal) && !_.isEmpty(newVal.pipeline)) {
this.fetchStages();
const stages = this.job.pipeline.details.stages || [];
const defaultStage = stages.find(stage => stage && stage.name === this.selectedStage);
if (defaultStage) {
this.fetchJobsForStage(defaultStage);
}
}
if (newVal.archived) {
......@@ -160,7 +167,7 @@ export default {
'setJobEndpoint',
'setTraceOptions',
'fetchJob',
'fetchStages',
'fetchJobsForStage',
'hideSidebar',
'showSidebar',
'toggleSidebar',
......@@ -269,7 +276,6 @@ export default {
:class="{ 'sticky-top border-bottom-0': hasTrace }"
>
<icon name="lock" class="align-text-bottom" />
{{ __('This job is archived. Only the complete pipeline can be retried.') }}
</div>
<!-- job log -->
......
......@@ -34,7 +34,7 @@ export default {
},
},
computed: {
...mapState(['job', 'stages', 'jobs', 'selectedStage', 'isLoadingStages']),
...mapState(['job', 'stages', 'jobs', 'selectedStage']),
coverage() {
return `${this.job.coverage}%`;
},
......@@ -208,7 +208,6 @@ export default {
/>
<stages-dropdown
v-if="!isLoadingStages"
:stages="stages"
:pipeline="job.pipeline"
:selected-stage="selectedStage"
......
......@@ -178,30 +178,6 @@ export const receiveTraceError = ({ commit }) => {
flash(__('An error occurred while fetching the job log.'));
};
/**
* Stages dropdown on sidebar
*/
export const requestStages = ({ commit }) => commit(types.REQUEST_STAGES);
export const fetchStages = ({ state, dispatch }) => {
dispatch('requestStages');
axios
.get(`${state.job.pipeline.path}.json`)
.then(({ data }) => {
// Set selected stage
dispatch('receiveStagesSuccess', data.details.stages);
const selectedStage = data.details.stages.find(stage => stage.name === state.selectedStage);
dispatch('fetchJobsForStage', selectedStage);
})
.catch(() => dispatch('receiveStagesError'));
};
export const receiveStagesSuccess = ({ commit }, data) =>
commit(types.RECEIVE_STAGES_SUCCESS, data);
export const receiveStagesError = ({ commit }) => {
commit(types.RECEIVE_STAGES_ERROR);
flash(__('An error occurred while fetching stages.'));
};
/**
* Jobs list on sidebar - depend on stages dropdown
*/
......@@ -209,7 +185,7 @@ export const requestJobsForStage = ({ commit }, stage) =>
commit(types.REQUEST_JOBS_FOR_STAGE, stage);
// On stage click, set selected stage + fetch job
export const fetchJobsForStage = ({ dispatch }, stage) => {
export const fetchJobsForStage = ({ dispatch }, stage = {}) => {
dispatch('requestJobsForStage', stage);
axios
......
......@@ -24,10 +24,6 @@ export const STOP_POLLING_TRACE = 'STOP_POLLING_TRACE';
export const RECEIVE_TRACE_SUCCESS = 'RECEIVE_TRACE_SUCCESS';
export const RECEIVE_TRACE_ERROR = 'RECEIVE_TRACE_ERROR';
export const REQUEST_STAGES = 'REQUEST_STAGES';
export const RECEIVE_STAGES_SUCCESS = 'RECEIVE_STAGES_SUCCESS';
export const RECEIVE_STAGES_ERROR = 'RECEIVE_STAGES_ERROR';
export const SET_SELECTED_STAGE = 'SET_SELECTED_STAGE';
export const REQUEST_JOBS_FOR_STAGE = 'REQUEST_JOBS_FOR_STAGE';
export const RECEIVE_JOBS_FOR_STAGE_SUCCESS = 'RECEIVE_JOBS_FOR_STAGE_SUCCESS';
......
......@@ -65,6 +65,11 @@ export default {
state.isLoading = false;
state.job = job;
state.stages =
job.pipeline && job.pipeline.details && job.pipeline.details.stages
? job.pipeline.details.stages
: [];
/**
* We only update it on the first request
* The dropdown can be changed by the user
......@@ -101,19 +106,7 @@ export default {
state.isScrolledToBottomBeforeReceivingTrace = toggle;
},
[types.REQUEST_STAGES](state) {
state.isLoadingStages = true;
},
[types.RECEIVE_STAGES_SUCCESS](state, stages) {
state.isLoadingStages = false;
state.stages = stages;
},
[types.RECEIVE_STAGES_ERROR](state) {
state.isLoadingStages = false;
state.stages = [];
},
[types.REQUEST_JOBS_FOR_STAGE](state, stage) {
[types.REQUEST_JOBS_FOR_STAGE](state, stage = {}) {
state.isLoadingJobs = true;
state.selectedStage = stage.name;
},
......
......@@ -25,7 +25,6 @@ export default () => ({
traceState: null,
// sidebar dropdown & list of jobs
isLoadingStages: false,
isLoadingJobs: false,
selectedStage: '',
stages: [],
......
......@@ -79,7 +79,12 @@ export const getDayName = date =>
* @param {date} datetime
* @returns {String}
*/
export const formatDate = datetime => dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
export const formatDate = datetime => {
if (_.isString(datetime) && datetime.match(/\d+-\d+\d+ /)) {
throw new Error('Invalid date');
}
return dateFormat(datetime, 'mmm d, yyyy h:MMtt Z');
};
/**
* Timeago uses underscores instead of dashes to separate language from country code.
......
......@@ -8,16 +8,14 @@ import {
GlLink,
} from '@gitlab/ui';
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import '~/vue_shared/mixins/is_ee';
import { getParameterValues } from '~/lib/utils/url_utility';
import Flash from '../../flash';
import MonitoringService from '../services/monitoring_service';
import MonitorAreaChart from './charts/area.vue';
import GraphGroup from './graph_group.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
import { timeWindows, timeWindowsKeyNames } from '../constants';
import { getTimeDiff } from '../utils';
......@@ -128,9 +126,7 @@ export default {
},
data() {
return {
store: new MonitoringStore(),
state: 'gettingStarted',
showEmptyState: true,
elWidth: 0,
selectedTimeWindow: '',
selectedTimeWindowKey: '',
......@@ -141,13 +137,21 @@ export default {
canAddMetrics() {
return this.customMetricsAvailable && this.customMetricsPath.length;
},
...mapState('monitoringDashboard', [
'groups',
'emptyState',
'showEmptyState',
'environments',
'deploymentData',
]),
},
created() {
this.service = new MonitoringService({
this.setEndpoints({
metricsEndpoint: this.metricsEndpoint,
deploymentEndpoint: this.deploymentEndpoint,
environmentsEndpoint: this.environmentsEndpoint,
deploymentsEndpoint: this.deploymentEndpoint,
});
this.timeWindows = timeWindows;
this.selectedTimeWindowKey =
_.escape(getParameterValues('time_window')[0]) || timeWindowsKeyNames.eightHours;
......@@ -165,31 +169,11 @@ export default {
}
},
mounted() {
const startEndWindow = getTimeDiff(this.timeWindows[this.selectedTimeWindowKey]);
this.servicePromises = [
this.service
.getGraphsData(startEndWindow)
.then(data => this.store.storeMetrics(data))
.catch(() => Flash(s__('Metrics|There was an error while retrieving metrics'))),
this.service
.getDeploymentData()
.then(data => this.store.storeDeploymentData(data))
.catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
];
if (!this.hasMetrics) {
this.state = 'gettingStarted';
this.setGettingStartedEmptyState();
} else {
if (this.environmentsEndpoint) {
this.servicePromises.push(
this.service
.getEnvironmentsData()
.then(data => this.store.storeEnvironmentsData(data))
.catch(() =>
Flash(s__('Metrics|There was an error getting environments information.')),
),
);
}
this.getGraphsData();
this.fetchData(getTimeDiff(this.timeWindows.eightHours));
sidebarMutationObserver = new MutationObserver(this.onSidebarMutation);
sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
attributes: true,
......@@ -199,6 +183,11 @@ export default {
}
},
methods: {
...mapActions('monitoringDashboard', [
'fetchData',
'setGettingStartedEmptyState',
'setEndpoints',
]),
getGraphAlerts(queries) {
if (!this.allAlerts) return {};
const metricIdsForChart = queries.map(q => q.metricId);
......@@ -207,21 +196,6 @@ export default {
getGraphAlertValues(queries) {
return Object.values(this.getGraphAlerts(queries));
},
getGraphsData() {
this.state = 'loading';
Promise.all(this.servicePromises)
.then(() => {
if (this.store.groups.length < 1) {
this.state = 'noData';
return;
}
this.showEmptyState = false;
})
.catch(() => {
this.state = 'unableToConnect';
});
},
hideAddMetricModal() {
this.$refs.addMetricModal.hide();
},
......@@ -263,10 +237,10 @@ export default {
class="prepend-left-10 js-environments-dropdown"
toggle-class="dropdown-menu-toggle"
:text="currentEnvironmentName"
:disabled="store.environmentsData.length === 0"
:disabled="environments.length === 0"
>
<gl-dropdown-item
v-for="environment in store.environmentsData"
v-for="environment in environments"
:key="environment.id"
:active="environment.name === currentEnvironmentName"
active-class="is-active"
......@@ -336,7 +310,7 @@ export default {
</div>
</div>
<graph-group
v-for="(groupData, index) in store.groups"
v-for="(groupData, index) in groups"
:key="index"
:name="groupData.group"
:show-panels="showPanels"
......@@ -345,7 +319,7 @@ export default {
v-for="(graphData, graphIndex) in groupData.metrics"
:key="graphIndex"
:graph-data="graphData"
:deployment-data="store.deploymentData"
:deployment-data="deploymentData"
:thresholds="getGraphAlertValues(graphData.queries)"
:container-width="elWidth"
group-id="monitor-area-chart"
......@@ -362,7 +336,7 @@ export default {
</div>
<empty-state
v-else
:selected-state="state"
:selected-state="emptyState"
:documentation-path="documentationPath"
:settings-path="settingsPath"
:clusters-path="clustersPath"
......
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import Dashboard from 'ee_else_ce/monitoring/components/dashboard.vue';
import store from './stores';
export default (props = {}) => {
const el = document.getElementById('prometheus-graphs');
......@@ -9,6 +10,7 @@ export default (props = {}) => {
// eslint-disable-next-line no-new
new Vue({
el,
store,
render(createElement) {
return createElement(Dashboard, {
props: {
......
import axios from '../../lib/utils/axios_utils';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
import { s__, __ } from '../../locale';
const MAX_REQUESTS = 3;
function backOffRequest(makeRequestCallback) {
let requestCounter = 0;
return backOff((next, stop) => {
makeRequestCallback()
.then(resp => {
if (resp.status === statusCodes.NO_CONTENT) {
requestCounter += 1;
if (requestCounter < MAX_REQUESTS) {
next();
} else {
stop(new Error(__('Failed to connect to the prometheus server')));
}
} else {
stop(resp);
}
})
.catch(stop);
});
}
export default class MonitoringService {
constructor({ metricsEndpoint, deploymentEndpoint, environmentsEndpoint }) {
this.metricsEndpoint = metricsEndpoint;
this.deploymentEndpoint = deploymentEndpoint;
this.environmentsEndpoint = environmentsEndpoint;
}
getGraphsData(params = {}) {
return backOffRequest(() => axios.get(this.metricsEndpoint, { params }))
.then(resp => resp.data)
.then(response => {
if (!response || !response.data || !response.success) {
throw new Error(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
}
return response.data;
});
}
getDeploymentData() {
if (!this.deploymentEndpoint) {
return Promise.resolve([]);
}
return backOffRequest(() => axios.get(this.deploymentEndpoint))
.then(resp => resp.data)
.then(response => {
if (!response || !response.deployments) {
throw new Error(
s__('Metrics|Unexpected deployment data response from prometheus endpoint'),
);
}
return response.deployments;
});
}
getEnvironmentsData() {
return axios
.get(this.environmentsEndpoint)
.then(resp => resp.data)
.then(response => {
if (!response || !response.environments) {
throw new Error(
s__('Metrics|There was an error fetching the environments data, please try again'),
);
}
return response.environments;
});
}
}
import * as types from './mutation_types';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import statusCodes from '../../lib/utils/http_status';
import { backOff } from '../../lib/utils/common_utils';
import { s__, __ } from '../../locale';
const MAX_REQUESTS = 3;
function backOffRequest(makeRequestCallback) {
let requestCounter = 0;
return backOff((next, stop) => {
makeRequestCallback()
.then(resp => {
if (resp.status === statusCodes.NO_CONTENT) {
requestCounter += 1;
if (requestCounter < MAX_REQUESTS) {
next();
} else {
stop(new Error(__('Failed to connect to the prometheus server')));
}
} else {
stop(resp);
}
})
.catch(stop);
});
}
export const setGettingStartedEmptyState = ({ commit }) => {
commit(types.SET_GETTING_STARTED_EMPTY_STATE);
};
export const setEndpoints = ({ commit }, endpoints) => {
commit(types.SET_ENDPOINTS, endpoints);
};
export const requestMetricsData = ({ commit }) => commit(types.REQUEST_METRICS_DATA);
export const receiveMetricsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_METRICS_DATA_SUCCESS, data);
export const receiveMetricsDataFailure = ({ commit }, error) =>
commit(types.RECEIVE_METRICS_DATA_FAILURE, error);
export const receiveDeploymentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS, data);
export const receiveDeploymentsDataFailure = ({ commit }) =>
commit(types.RECEIVE_DEPLOYMENTS_DATA_FAILURE);
export const receiveEnvironmentsDataSuccess = ({ commit }, data) =>
commit(types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS, data);
export const receiveEnvironmentsDataFailure = ({ commit }) =>
commit(types.RECEIVE_ENVIRONMENTS_DATA_FAILURE);
export const fetchData = ({ dispatch }, params) => {
dispatch('fetchMetricsData', params);
dispatch('fetchDeploymentsData');
dispatch('fetchEnvironmentsData');
};
export const fetchMetricsData = ({ state, dispatch }, params) => {
dispatch('requestMetricsData');
return backOffRequest(() => axios.get(state.metricsEndpoint, { params }))
.then(resp => resp.data)
.then(response => {
if (!response || !response.data || !response.success) {
dispatch('receiveMetricsDataFailure', null);
createFlash(s__('Metrics|Unexpected metrics data response from prometheus endpoint'));
}
dispatch('receiveMetricsDataSuccess', response.data);
})
.catch(error => {
dispatch('receiveMetricsDataFailure', error);
createFlash(s__('Metrics|There was an error while retrieving metrics'));
});
};
export const fetchDeploymentsData = ({ state, dispatch }) => {
if (!state.deploymentEndpoint) {
return Promise.resolve([]);
}
return backOffRequest(() => axios.get(state.deploymentEndpoint))
.then(resp => resp.data)
.then(response => {
if (!response || !response.deployments) {
createFlash(s__('Metrics|Unexpected deployment data response from prometheus endpoint'));
}
dispatch('receiveDeploymentsDataSuccess', response.deployments);
})
.catch(() => {
dispatch('receiveDeploymentsDataFailure');
createFlash(s__('Metrics|There was an error getting deployment information.'));
});
};
export const fetchEnvironmentsData = ({ state, dispatch }) => {
if (!state.environmentsEndpoint) {
return Promise.resolve([]);
}
return axios
.get(state.environmentsEndpoint)
.then(resp => resp.data)
.then(response => {
if (!response || !response.environments) {
createFlash(
s__('Metrics|There was an error fetching the environments data, please try again'),
);
}
dispatch('receiveEnvironmentsDataSuccess', response.environments);
})
.catch(() => {
dispatch('receiveEnvironmentsDataFailure');
createFlash(s__('Metrics|There was an error getting environments information.'));
});
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import state from './state';
Vue.use(Vuex);
export const createStore = () =>
new Vuex.Store({
modules: {
monitoringDashboard: {
namespaced: true,
actions,
mutations,
state,
},
},
});
export default createStore();
export const REQUEST_METRICS_DATA = 'REQUEST_METRICS_DATA';
export const RECEIVE_METRICS_DATA_SUCCESS = 'RECEIVE_METRICS_DATA_SUCCESS';
export const RECEIVE_METRICS_DATA_FAILURE = 'RECEIVE_METRICS_DATA_FAILURE';
export const REQUEST_DEPLOYMENTS_DATA = 'REQUEST_DEPLOYMENTS_DATA';
export const RECEIVE_DEPLOYMENTS_DATA_SUCCESS = 'RECEIVE_DEPLOYMENTS_DATA_SUCCESS';
export const RECEIVE_DEPLOYMENTS_DATA_FAILURE = 'RECEIVE_DEPLOYMENTS_DATA_FAILURE';
export const REQUEST_ENVIRONMENTS_DATA = 'REQUEST_ENVIRONMENTS_DATA';
export const RECEIVE_ENVIRONMENTS_DATA_SUCCESS = 'RECEIVE_ENVIRONMENTS_DATA_SUCCESS';
export const RECEIVE_ENVIRONMENTS_DATA_FAILURE = 'RECEIVE_ENVIRONMENTS_DATA_FAILURE';
export const SET_TIME_WINDOW = 'SET_TIME_WINDOW';
export const SET_METRICS_ENDPOINT = 'SET_METRICS_ENDPOINT';
export const SET_ENVIRONMENTS_ENDPOINT = 'SET_ENVIRONMENTS_ENDPOINT';
export const SET_DEPLOYMENTS_ENDPOINT = 'SET_DEPLOYMENTS_ENDPOINT';
export const SET_ENDPOINTS = 'SET_ENDPOINTS';
export const SET_GETTING_STARTED_EMPTY_STATE = 'SET_GETTING_STARTED_EMPTY_STATE';
import * as types from './mutation_types';
import { normalizeMetrics, sortMetrics } from './utils';
export default {
[types.REQUEST_METRICS_DATA](state) {
state.emptyState = 'loading';
state.showEmptyState = true;
},
[types.RECEIVE_METRICS_DATA_SUCCESS](state, groupData) {
state.groups = groupData.map(group => ({
...group,
metrics: normalizeMetrics(sortMetrics(group.metrics)),
}));
if (!state.groups.length) {
state.emptyState = 'noData';
} else {
state.showEmptyState = false;
}
},
[types.RECEIVE_METRICS_DATA_FAILURE](state, error) {
state.emptyState = error ? 'unableToConnect' : 'noData';
state.showEmptyState = true;
},
[types.RECEIVE_DEPLOYMENTS_DATA_SUCCESS](state, deployments) {
state.deploymentData = deployments;
},
[types.RECEIVE_DEPLOYMENTS_DATA_FAILURE](state) {
state.deploymentData = [];
},
[types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS](state, environments) {
state.environments = environments;
},
[types.RECEIVE_ENVIRONMENTS_DATA_FAILURE](state) {
state.environments = [];
},
[types.SET_ENDPOINTS](state, endpoints) {
state.metricsEndpoint = endpoints.metricsEndpoint;
state.environmentsEndpoint = endpoints.environmentsEndpoint;
state.deploymentsEndpoint = endpoints.deploymentsEndpoint;
},
[types.SET_GETTING_STARTED_EMPTY_STATE](state) {
state.emptyState = 'gettingStarted';
},
};
export default () => ({
hasMetrics: false,
showPanels: true,
metricsEndpoint: null,
environmentsEndpoint: null,
deploymentsEndpoint: null,
emptyState: 'gettingStarted',
showEmptyState: true,
groups: [],
deploymentData: [],
environments: [],
});
import _ from 'underscore';
function sortMetrics(metrics) {
return _.chain(metrics)
.sortBy('title')
.sortBy('weight')
.value();
}
function checkQueryEmptyData(query) {
return {
...query,
......@@ -59,7 +52,13 @@ function groupQueriesByChartInfo(metrics) {
return Object.values(metricsByChart);
}
function normalizeMetrics(metrics) {
export const sortMetrics = metrics =>
_.chain(metrics)
.sortBy('title')
.sortBy('weight')
.value();
export const normalizeMetrics = metrics => {
const groupedMetrics = groupQueriesByChartInfo(metrics);
return groupedMetrics.map(metric => {
......@@ -81,31 +80,4 @@ function normalizeMetrics(metrics) {
queries: removeTimeSeriesNoData(queries),
};
});
}
export default class MonitoringStore {
constructor() {
this.groups = [];
this.deploymentData = [];
this.environmentsData = [];
}
storeMetrics(groups = []) {
this.groups = groups.map(group => ({
...group,
metrics: normalizeMetrics(sortMetrics(group.metrics)),
}));
}
storeDeploymentData(deploymentData = []) {
this.deploymentData = deploymentData;
}
storeEnvironmentsData(environmentsData = []) {
this.environmentsData = environmentsData.filter(environment => !!environment.last_deployment);
}
getMetricsCount() {
return this.groups.reduce((count, group) => count + group.metrics.length, 0);
}
}
};
......@@ -69,7 +69,9 @@ export default {
>
<ci-icon :status="group.status" />
<span class="ci-status-text"> {{ group.name }} </span>
<span class="ci-status-text text-truncate mw-70p gl-pl-1 d-inline-block align-bottom">
{{ group.name }}
</span>
<span class="dropdown-counter-badge"> {{ group.size }} </span>
</button>
......
......@@ -7,6 +7,7 @@ import getFiles from '../../queries/getFiles.graphql';
import getProjectPath from '../../queries/getProjectPath.graphql';
import TableHeader from './header.vue';
import TableRow from './row.vue';
import ParentRow from './parent_row.vue';
const PAGE_SIZE = 100;
......@@ -15,6 +16,7 @@ export default {
GlLoadingIcon,
TableHeader,
TableRow,
ParentRow,
},
mixins: [getRefMixin],
apollo: {
......@@ -47,6 +49,9 @@ export default {
{ path: this.path, ref: this.ref },
);
},
showParentRow() {
return !this.isLoadingFiles && this.path !== '';
},
},
watch: {
$route: function routeChange() {
......@@ -120,6 +125,7 @@ export default {
</caption>
<table-header v-once />
<tbody>
<parent-row v-show="showParentRow" :commit-ref="ref" :path="path" />
<template v-for="val in entries">
<table-row
v-for="entry in val"
......
<script>
export default {
props: {
commitRef: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
},
computed: {
parentRoute() {
const splitArray = this.path.split('/');
splitArray.pop();
return { path: `/tree/${this.commitRef}/${splitArray.join('/')}` };
},
},
methods: {
clickRow() {
this.$router.push(this.parentRoute);
},
},
};
</script>
<template>
<tr v-once @click="clickRow">
<td colspan="3" class="tree-item-file-name">
<router-link :to="parentRoute" :aria-label="__('Go to parent')">
..
</router-link>
</td>
</tr>
</template>
......@@ -2,10 +2,12 @@ import Vue from 'vue';
import createRouter from './router';
import App from './components/app.vue';
import apolloProvider from './graphql';
import { setTitle } from './utils/title';
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { projectPath, ref } = el.dataset;
const { projectPath, ref, fullName } = el.dataset;
const router = createRouter(projectPath, ref);
apolloProvider.clients.defaultClient.cache.writeData({
data: {
......@@ -14,9 +16,11 @@ export default function setupVueRepositoryList() {
},
});
router.afterEach(({ params: { pathMatch } }) => setTitle(pathMatch, ref, fullName));
return new Vue({
el,
router: createRouter(projectPath, ref),
router,
apolloProvider,
render(h) {
return h(App);
......
// eslint-disable-next-line import/prefer-default-export
export const setTitle = (pathMatch, ref, project) => {
const path = pathMatch.replace(/^\//, '');
const isEmpty = path === '';
document.title = `${isEmpty ? 'Files' : path} · ${ref} · ${project}`;
};
$avatar-sizes: (
16: (
font-size: 10px,
line-height: 16px,
border-radius: $border-radius-small
),
18: (
border-radius: $border-radius-small
),
19: (
border-radius: $border-radius-small
),
20: (
border-radius: $border-radius-small
),
24: (
font-size: 12px,
line-height: 24px,
border-radius: $border-radius-default
),
26: (
font-size: 20px,
line-height: 1.33,
border-radius: $border-radius-default
),
32: (
font-size: 14px,
line-height: 32px,
border-radius: $border-radius-default
),
36: (
border-radius: $border-radius-default
),
40: (
font-size: 16px,
line-height: 38px,
border-radius: $border-radius-default
),
46: (
border-radius: $border-radius-default
),
48: (
font-size: 20px,
line-height: 48px,
border-radius: $border-radius-large
),
60: (
font-size: 32px,
line-height: 58px,
border-radius: $border-radius-large
),
64: (
font-size: 28px,
line-height: 64px,
border-radius: $border-radius-large
),
70: (
font-size: 34px,
line-height: 70px,
border-radius: $border-radius-large
),
90: (
font-size: 36px,
line-height: 88px,
border-radius: $border-radius-large
),
96: (
font-size: 48px,
line-height: 96px,
border-radius: $border-radius-large
),
100: (
font-size: 36px,
line-height: 98px,
border-radius: $border-radius-large
),
110: (
font-size: 40px,
line-height: 108px,
font-weight: $gl-font-weight-normal,
border-radius: $border-radius-large
),
140: (
font-size: 72px,
line-height: 138px,
border-radius: $border-radius-large
),
160: (
font-size: 96px,
line-height: 158px,
border-radius: $border-radius-large
)
);
$identicon-backgrounds: $identicon-red, $identicon-purple, $identicon-indigo, $identicon-blue, $identicon-teal,
$identicon-orange, $gray-darker;
.avatar-circle {
float: left;
margin-right: 15px;
border-radius: $avatar-radius;
border: 1px solid $gray-normal;
&.s16 { @include avatar-size(16px, 8px); }
&.s18 { @include avatar-size(18px, 8px); }
&.s19 { @include avatar-size(19px, 8px); }
&.s20 { @include avatar-size(20px, 8px); }
&.s24 { @include avatar-size(24px, 8px); }
&.s26 { @include avatar-size(26px, 8px); }
&.s32 { @include avatar-size(32px, 8px); }
&.s36 { @include avatar-size(36px, 16px); }
&.s40 { @include avatar-size(40px, 16px); }
&.s46 { @include avatar-size(46px, 16px); }
&.s48 { @include avatar-size(48px, 16px); }
&.s60 { @include avatar-size(60px, 16px); }
&.s64 { @include avatar-size(64px, 16px); }
&.s70 { @include avatar-size(70px, 16px); }
&.s90 { @include avatar-size(90px, 16px); }
&.s96 { @include avatar-size(96px, 16px); }
&.s100 { @include avatar-size(100px, 16px); }
&.s110 { @include avatar-size(110px, 16px); }
&.s140 { @include avatar-size(140px, 16px); }
&.s160 { @include avatar-size(160px, 16px); }
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
@include avatar-size(#{$size}px, if($size < 36, 8px, 16px));
}
}
}
.avatar {
......@@ -42,8 +125,13 @@
margin-left: 2px;
flex-shrink: 0;
&.s16 { margin-right: 4px; }
&.s24 { margin-right: 4px; }
&.s16 {
margin-right: 4px;
}
&.s24 {
margin-right: 4px;
}
}
&.center {
......@@ -69,60 +157,25 @@
background-color: $gray-darker;
// Sizes
&.s16 { font-size: 10px;
line-height: 16px; }
&.s24 { font-size: 12px;
line-height: 24px; }
&.s26 { font-size: 20px;
line-height: 1.33; }
&.s32 { font-size: 14px;
line-height: 32px; }
&.s40 { font-size: 16px;
line-height: 38px; }
&.s48 { font-size: 20px;
line-height: 48px; }
&.s60 { font-size: 32px;
line-height: 58px; }
&.s64 { font-size: 28px;
line-height: 64px; }
&.s70 { font-size: 34px;
line-height: 70px; }
&.s90 { font-size: 36px;
line-height: 88px; }
&.s96 { font-size: 48px;
line-height: 96px; }
&.s100 { font-size: 36px;
line-height: 98px; }
&.s110 { font-size: 40px;
line-height: 108px;
font-weight: $gl-font-weight-normal; }
&.s140 { font-size: 72px;
line-height: 138px; }
&.s160 { font-size: 96px;
line-height: 158px; }
@each $size, $size-config in $avatar-sizes {
$keys: map-keys($size-config);
&.s#{$size} {
@each $key in $keys {
// We don't want `border-radius` to be included here.
@if ($key != 'border-radius') {
#{$key}: map-get($size-config, #{$key});
}
}
}
}
// Background colors
&.bg1 { background-color: $identicon-red; }
&.bg2 { background-color: $identicon-purple; }
&.bg3 { background-color: $identicon-indigo; }
&.bg4 { background-color: $identicon-blue; }
&.bg5 { background-color: $identicon-teal; }
&.bg6 { background-color: $identicon-orange; }
&.bg7 { background-color: $gray-darker; }
@for $i from 1 through length($identicon-backgrounds) {
&.bg#{$i} {
background-color: nth($identicon-backgrounds, $i);
}
}
}
.avatar-container {
......@@ -139,41 +192,32 @@
.avatar {
border-radius: 0;
border: 0;
height: auto;
width: 100%;
margin: 0;
align-self: center;
}
&.s40 { min-width: 40px;
min-height: 40px; }
&.s40 {
min-width: 40px;
min-height: 40px;
}
&.s64 { min-width: 64px;
min-height: 64px; }
&.s64 {
min-width: 64px;
min-height: 64px;
}
}
.rect-avatar {
border-radius: $border-radius-small;
&.s16 { border-radius: $border-radius-small; }
&.s18 { border-radius: $border-radius-small; }
&.s19 { border-radius: $border-radius-small; }
&.s20 { border-radius: $border-radius-small; }
&.s24 { border-radius: $border-radius-default; }
&.s26 { border-radius: $border-radius-default; }
&.s32 { border-radius: $border-radius-default; }
&.s36 { border-radius: $border-radius-default; }
&.s40 { border-radius: $border-radius-default; }
&.s46 { border-radius: $border-radius-default; }
&.s48 { border-radius: $border-radius-large; }
&.s60 { border-radius: $border-radius-large; }
&.s64 { border-radius: $border-radius-large; }
&.s70 { border-radius: $border-radius-large; }
&.s90 { border-radius: $border-radius-large; }
&.s96 { border-radius: $border-radius-large; }
&.s100 { border-radius: $border-radius-large; }
&.s110 { border-radius: $border-radius-large; }
&.s140 { border-radius: $border-radius-large; }
&.s160 { border-radius: $border-radius-large; }
@each $size, $size-config in $avatar-sizes {
&.s#{$size} {
border-radius: map-get($size-config, 'border-radius');
}
}
}
.avatar-counter {
......
......@@ -21,7 +21,7 @@
}
@mixin btn-default {
border-radius: 3px;
border-radius: $border-radius-default;
font-size: $gl-font-size;
font-weight: $gl-font-weight-normal;
padding: $gl-vert-padding $gl-btn-padding;
......@@ -37,7 +37,7 @@
@include btn-default;
}
@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border) {
@mixin btn-outline($background, $text, $border, $hover-background, $hover-text, $hover-border, $active-background, $active-border, $active-text) {
background-color: $background;
color: $text;
border-color: $border;
......@@ -61,13 +61,22 @@
}
}
&:focus {
box-shadow: 0 0 4px 1px $blue-300;
}
&:active {
background-color: $active-background;
border-color: $active-border;
color: $hover-text;
box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
color: $active-text;
> .icon {
color: $hover-text;
color: $active-text;
}
&:focus {
box-shadow: inset 0 2px 4px 0 rgba($black, 0.2);
}
}
}
......@@ -164,21 +173,21 @@
&.btn-inverted {
&.btn-success {
@include btn-outline($white-light, $green-600, $green-500, $green-500, $white-light, $green-600, $green-600, $green-700);
@include btn-outline($white-light, $green-600, $green-500, $green-100, $green-700, $green-500, $green-200, $green-600, $green-800);
}
&.btn-remove,
&.btn-danger {
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
@include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-warning {
@include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
@include btn-outline($white-light, $orange-500, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-primary,
&.btn-info {
@include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
@include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
......@@ -193,11 +202,11 @@
&.btn-close,
&.btn-close-color {
@include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
@include btn-outline($white-light, $orange-600, $orange-500, $orange-100, $orange-700, $orange-500, $orange-200, $orange-600, $orange-800);
}
&.btn-spam {
@include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700);
@include btn-outline($white-light, $red-500, $red-500, $red-100, $red-700, $red-500, $red-200, $red-600, $red-800);
}
&.btn-danger,
......@@ -402,7 +411,7 @@
.btn-inverted {
&-secondary {
@include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700);
@include btn-outline($white-light, $blue-500, $blue-500, $blue-100, $blue-700, $blue-500, $blue-200, $blue-600, $blue-800);
}
}
......
......@@ -69,6 +69,8 @@
align-self: flex-start;
font-weight: 500;
font-size: 20px;
color: $orange-900;
opacity: 1;
margin: $gl-padding-8 14px 0 0;
}
......
......@@ -42,8 +42,8 @@
.login-box,
.omniauth-container {
box-shadow: 0 0 0 1px $border-color;
border-bottom-right-radius: $border-radius-small;
border-bottom-left-radius: $border-radius-small;
border-bottom-right-radius: $border-radius;
border-bottom-left-radius: $border-radius;
padding: 15px;
.login-heading h3 {
......@@ -88,7 +88,7 @@
}
.omniauth-container {
border-radius: $border-radius-small;
border-radius: $border-radius;
font-size: 13px;
p {
......
......@@ -261,3 +261,13 @@ input[type='checkbox']:hover {
color: $blue-600;
}
}
// Disable webkit input icons, link to solution: https://stackoverflow.com/questions/9421551/how-do-i-remove-all-default-webkit-search-field-styling
/* stylelint-disable property-no-vendor-prefix */
input[type='search']::-webkit-search-decoration,
input[type='search']::-webkit-search-cancel-button,
input[type='search']::-webkit-search-results-button,
input[type='search']::-webkit-search-results-decoration {
-webkit-appearance: none;
}
/* stylelint-enable */
......@@ -15,7 +15,8 @@ class Admin::LogsController < Admin::ApplicationController
Gitlab::EnvironmentLogger,
Gitlab::SidekiqLogger,
Gitlab::RepositoryCheckLogger,
Gitlab::ProjectServiceLogger
Gitlab::ProjectServiceLogger,
Gitlab::Kubernetes::Logger
]
end
end
......@@ -16,13 +16,8 @@ class GraphqlController < ApplicationController
before_action(only: [:execute]) { authenticate_sessionless_user!(:api) }
def execute
variables = Gitlab::Graphql::Variables.new(params[:variables]).to_h
query = params[:query]
operation_name = params[:operationName]
context = {
current_user: current_user
}
result = GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
result = multiplex? ? execute_multiplex : execute_query
render json: result
end
......@@ -38,6 +33,43 @@ class GraphqlController < ApplicationController
private
def execute_multiplex
GitlabSchema.multiplex(multiplex_queries, context: context)
end
def execute_query
variables = build_variables(params[:variables])
operation_name = params[:operationName]
GitlabSchema.execute(query, variables: variables, context: context, operation_name: operation_name)
end
def query
params[:query]
end
def multiplex_queries
params[:_json].map do |single_query_info|
{
query: single_query_info[:query],
variables: build_variables(single_query_info[:variables]),
operation_name: single_query_info[:operationName]
}
end
end
def context
@context ||= { current_user: current_user }
end
def build_variables(variable_info)
Gitlab::Graphql::Variables.new(variable_info).to_h
end
def multiplex?
params[:_json].present?
end
def authorize_access_api!
access_denied!("API not accessible for user.") unless can?(current_user, :access_api)
end
......
......@@ -14,9 +14,9 @@ class Profiles::NotificationsController < Profiles::ApplicationController
result = Users::UpdateService.new(current_user, user_params.merge(user: current_user)).execute
if result[:status] == :success
flash[:notice] = "Notification settings saved"
flash[:notice] = _("Notification settings saved")
else
flash[:alert] = "Failed to save new settings"
flash[:alert] = _("Failed to save new settings")
end
redirect_back_or_default(default: profile_notifications_path)
......
......@@ -9,25 +9,18 @@ class MembersFinder
@group = project.group
end
# rubocop: disable CodeReuse/ActiveRecord
def execute(include_descendants: false)
def execute(include_descendants: false, include_invited_groups_members: false)
project_members = project.project_members
project_members = project_members.non_invite unless can?(current_user, :admin_project, project)
if group
group_members = GroupMembersFinder.new(group).execute(include_descendants: include_descendants) # rubocop: disable CodeReuse/Finder
group_members = group_members.non_invite
union_members = group_union_members(include_descendants, include_invited_groups_members)
union = Gitlab::SQL::Union.new([project_members, group_members], remove_duplicates: false) # rubocop: disable Gitlab/Union
sql = distinct_on(union)
Member.includes(:user).from("(#{sql}) AS #{Member.table_name}")
if union_members.any?
distinct_union_of_members(union_members << project_members)
else
project_members
end
end
# rubocop: enable CodeReuse/ActiveRecord
def can?(*args)
Ability.allowed?(*args)
......@@ -35,6 +28,34 @@ class MembersFinder
private
def group_union_members(include_descendants, include_invited_groups_members)
[].tap do |members|
members << direct_group_members(include_descendants) if group
members << project_invited_groups_members if include_invited_groups_members
end
end
def direct_group_members(include_descendants)
GroupMembersFinder.new(group).execute(include_descendants: include_descendants).non_invite # rubocop: disable CodeReuse/Finder
end
def project_invited_groups_members
invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy
.new(project.invited_groups)
.base_and_ancestors
.public_or_visible_to_user(current_user)
.select(:id)
GroupMember.with_source_id(invited_groups_ids_including_ancestors)
end
def distinct_union_of_members(union_members)
union = Gitlab::SQL::Union.new(union_members, remove_duplicates: false) # rubocop: disable Gitlab/Union
sql = distinct_on(union)
Member.includes(:user).from([Arel.sql("(#{sql}) AS #{Member.table_name}")]) # rubocop: disable CodeReuse/ActiveRecord
end
def distinct_on(union)
# We're interested in a list of members without duplicates by user_id.
# We prefer project members over group members, project members should go first.
......
......@@ -7,7 +7,7 @@ class GitlabSchema < GraphQL::Schema
AUTHENTICATED_COMPLEXITY = 250
ADMIN_COMPLEXITY = 300
ANONYMOUS_MAX_DEPTH = 10
DEFAULT_MAX_DEPTH = 10
AUTHENTICATED_MAX_DEPTH = 15
use BatchLoader::GraphQL
......@@ -23,10 +23,21 @@ class GitlabSchema < GraphQL::Schema
default_max_page_size 100
max_complexity DEFAULT_MAX_COMPLEXITY
max_depth DEFAULT_MAX_DEPTH
mutation(Types::MutationType)
class << self
def multiplex(queries, **kwargs)
kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
queries.each do |query|
query[:max_depth] = max_query_depth(kwargs[:context])
end
super(queries, **kwargs)
end
def execute(query_str = nil, **kwargs)
kwargs[:max_complexity] ||= max_query_complexity(kwargs[:context])
kwargs[:max_depth] ||= max_query_depth(kwargs[:context])
......@@ -54,7 +65,7 @@ class GitlabSchema < GraphQL::Schema
if current_user
AUTHENTICATED_MAX_DEPTH
else
ANONYMOUS_MAX_DEPTH
DEFAULT_MAX_DEPTH
end
end
end
......
......@@ -5,7 +5,7 @@ module LabelsHelper
include ActionView::Helpers::TagHelper
def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil)
return true if label.is_a?(GroupLabel)
return true unless label.project_label?
return true unless project
project.feature_available?(issuables_type, current_user)
......@@ -159,13 +159,6 @@ module LabelsHelper
label.subscribed?(current_user, project) ? 'Unsubscribe' : 'Subscribe'
end
def label_deletion_confirm_text(label)
case label
when GroupLabel then _('Remove this label? This will affect all projects within the group. Are you sure?')
when ProjectLabel then _('Remove this label? Are you sure?')
end
end
def create_label_title(subject)
case subject
when Group
......@@ -200,7 +193,7 @@ module LabelsHelper
end
def label_status_tooltip(label, status)
type = label.is_a?(ProjectLabel) ? 'project' : 'group'
type = label.project_label? ? 'project' : 'group'
level = status.unsubscribed? ? type : status.sub('-level', '')
action = status.unsubscribed? ? 'Subscribe' : 'Unsubscribe'
......
......@@ -319,6 +319,30 @@ module ProjectsHelper
Ability.allowed?(current_user, :admin_project_member, @project)
end
def project_can_be_shared?
!membership_locked? || @project.allowed_to_share_with_group?
end
def membership_locked?
false
end
def share_project_description(project)
share_with_group = project.allowed_to_share_with_group?
share_with_members = !membership_locked?
description =
if share_with_group && share_with_members
_("You can invite a new member to <strong>%{project_name}</strong> or invite another group.")
elsif share_with_group
_("You can invite another group to <strong>%{project_name}</strong>.")
elsif share_with_members
_("You can invite a new member to <strong>%{project_name}</strong>.")
end
description.html_safe % { project_name: project.name }
end
private
def get_project_nav_tabs(project, current_user)
......
......@@ -257,6 +257,12 @@ class ApplicationSetting < ApplicationRecord
algorithm: 'aes-256-gcm',
encode: true
attr_encrypted :lets_encrypt_private_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
algorithm: 'aes-256-gcm',
encode: true
before_validation :ensure_uuid!
before_validation :strip_sentry_values
......
......@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
VERSION = '0.5.0'.freeze
VERSION = '0.5.1'.freeze
self.table_name = 'clusters_applications_runners'
......
......@@ -5,8 +5,10 @@ module Clusters
include Presentable
include Gitlab::Utils::StrongMemoize
include FromUnion
include ReactiveCaching
self.table_name = 'clusters'
self.reactive_cache_key = -> (cluster) { [cluster.class.model_name.singular, cluster.id] }
PROJECT_ONLY_APPLICATIONS = {
Applications::Jupyter.application_name => Applications::Jupyter,
......@@ -57,6 +59,8 @@ module Clusters
validate :no_groups, unless: :group_type?
validate :no_projects, unless: :project_type?
after_save :clear_reactive_cache!
delegate :status, to: :provider, allow_nil: true
delegate :status_reason, to: :provider, allow_nil: true
delegate :on_creation?, to: :provider, allow_nil: true
......@@ -123,15 +127,19 @@ module Clusters
end
def status_name
if provider
provider.status_name
else
:created
provider&.status_name || connection_status.presence || :created
end
def connection_status
with_reactive_cache do |data|
data[:connection_status]
end
end
def created?
status_name == :created
def calculate_reactive_cache
return unless enabled?
{ connection_status: retrieve_connection_status }
end
def applications
......@@ -221,6 +229,51 @@ module Clusters
@instance_domain ||= Gitlab::CurrentSettings.auto_devops_domain
end
def retrieve_connection_status
kubeclient.core_client.discover
rescue *Gitlab::Kubernetes::Errors::CONNECTION
:unreachable
rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION
:authentication_failure
rescue Kubeclient::HttpError => e
kubeclient_error_status(e.message)
rescue => e
Gitlab::Sentry.track_acceptable_exception(e, extra: { cluster_id: id })
:unknown_failure
else
:connected
end
# KubeClient uses the same error class
# For connection errors (eg. timeout) and
# for Kubernetes errors.
def kubeclient_error_status(message)
if message&.match?(/timed out|timeout/i)
:unreachable
else
:authentication_failure
end
end
# To keep backward compatibility with AUTO_DEVOPS_DOMAIN
# environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
# is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
# ProjectAutoDevops#Domain, project variables or group variables,
# as the AUTO_DEVOPS_DOMAIN is needed for CI_ENVIRONMENT_URL
#
# This method should is scheduled to be removed on
# https://gitlab.com/gitlab-org/gitlab-ce/issues/56959
def legacy_auto_devops_domain
if project_type?
project&.auto_devops&.domain.presence ||
project.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence ||
project.group&.variables&.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
elsif group_type?
group.variables.find_by(key: 'AUTO_DEVOPS_DOMAIN')&.value.presence
end
end
def restrict_modification
if provider&.on_creation?
errors.add(:base, "cannot modify during creation")
......
......@@ -80,6 +80,8 @@ class Member < ApplicationRecord
scope :owners_and_masters, -> { owners_and_maintainers } # @deprecated
scope :with_user, -> (user) { where(user: user) }
scope :with_source_id, ->(source_id) { where(source_id: source_id) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
......
......@@ -2,11 +2,11 @@
class PipelinesEmailService < Service
prop_accessor :recipients
boolean_accessor :notify_only_broken_pipelines
boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
validates :recipients, presence: true, if: :valid_recipients?
def initialize_properties
self.properties ||= { notify_only_broken_pipelines: true }
self.properties ||= { notify_only_broken_pipelines: true, notify_only_default_branch: false }
end
def title
......@@ -54,7 +54,9 @@ class PipelinesEmailService < Service
placeholder: _('Emails separated by comma'),
required: true },
{ type: 'checkbox',
name: 'notify_only_broken_pipelines' }
name: 'notify_only_broken_pipelines' },
{ type: 'checkbox',
name: 'notify_only_default_branch' }
]
end
......@@ -67,6 +69,16 @@ class PipelinesEmailService < Service
end
def should_pipeline_be_notified?(data)
notify_for_pipeline_branch?(data) && notify_for_pipeline?(data)
end
def notify_for_pipeline_branch?(data)
return true unless notify_only_default_branch?
data[:object_attributes][:ref] == data[:project][:default_branch]
end
def notify_for_pipeline?(data)
case data[:object_attributes][:status]
when 'success'
!notify_only_broken_pipelines?
......
......@@ -1077,7 +1077,7 @@ class Repository
end
def rebase(user, merge_request)
if Feature.disabled?(:two_step_rebase, default_enabled: true)
if Feature.disabled?(:two_step_rebase, default_enabled: false)
return rebase_deprecated(user, merge_request)
end
......
......@@ -22,10 +22,6 @@ module Clusters
"https://console.cloud.google.com/kubernetes/clusters/details/#{provider.zone}/#{name}" if gcp?
end
def can_toggle_cluster?
can?(current_user, :update_cluster, cluster) && created?
end
def can_read_cluster?
can?(current_user, :read_cluster, cluster)
end
......
......@@ -35,6 +35,14 @@ class LabelPresenter < Gitlab::View::Presenter::Delegated
issuable_subject.is_a?(Project) && label.is_a?(GroupLabel)
end
def project_label?
label.is_a?(ProjectLabel)
end
def subject_name
label.subject.name
end
private
def context_subject
......
......@@ -9,7 +9,8 @@ class AnalyticsStageEntity < Grape::Entity
expose :description
expose :median, as: :value do |stage|
# median returns a BatchLoader instance which we first have to unwrap by using to_i
!stage.median.to_i.zero? ? distance_of_time_in_words(stage.median) : nil
# median returns a BatchLoader instance which we first have to unwrap by using to_f
# we use to_f to make sure results below 1 are presented to the end-user
stage.median.to_f.nonzero? ? distance_of_time_in_words(stage.median) : nil
end
end
......@@ -8,16 +8,18 @@ class BuildDetailsEntity < JobEntity
expose :stuck?, as: :stuck
expose :user, using: UserEntity
expose :runner, using: RunnerEntity
expose :metadata, using: BuildMetadataEntity
expose :pipeline, using: PipelineEntity
expose :deployment_status, if: -> (*) { build.starts_environment? } do
expose :deployment_status, as: :status
expose :persisted_environment, as: :environment, with: EnvironmentEntity
expose :persisted_environment, as: :environment do |build, options|
options.merge(deployment_details: false).yield_self do |opts|
EnvironmentEntity.represent(build.persisted_environment, opts)
end
end
end
expose :metadata, using: BuildMetadataEntity
expose :artifact, if: -> (*) { can?(current_user, :read_build, build) } do
expose :download_path, if: -> (*) { build.artifacts? } do |build|
download_project_job_artifacts_path(project, build)
......
......@@ -20,16 +20,39 @@ class DeploymentEntity < Grape::Entity
expose :created_at
expose :tag
expose :last?
expose :user, using: UserEntity
expose :commit, using: CommitEntity
expose :deployable, using: JobEntity
expose :manual_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
expose :scheduled_actions, using: JobEntity, if: -> (*) { can_create_deployment? }
expose :deployable do |deployment, opts|
deployment.deployable.yield_self do |deployable|
if include_details?
JobEntity.represent(deployable, opts)
elsif can_read_deployables?
{ name: deployable.name,
build_path: project_job_path(deployable.project, deployable) }
end
end
end
expose :commit, using: CommitEntity, if: -> (*) { include_details? }
expose :manual_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
expose :scheduled_actions, using: JobEntity, if: -> (*) { include_details? && can_create_deployment? }
private
def include_details?
options.fetch(:deployment_details, true)
end
def can_create_deployment?
can?(request.current_user, :create_deployment, request.project)
end
def can_read_deployables?
##
# We intentionally do not check `:read_build, deployment.deployable`
# because it triggers a policy evaluation that involves multiple
# Gitaly calls that might not be cached.
#
can?(request.current_user, :read_build, request.project)
end
end
# frozen_string_literal: true
class PipelineDetailsEntity < PipelineEntity
expose :flags do
expose :latest?, as: :latest
end
expose :details do
expose :ordered_stages, as: :stages, using: StageEntity
expose :artifacts, using: BuildArtifactEntity
expose :manual_actions, using: BuildActionEntity
expose :scheduled_actions, using: BuildActionEntity
......
......@@ -20,7 +20,6 @@ class PipelineEntity < Grape::Entity
end
expose :flags do
expose :latest?, as: :latest
expose :stuck?, as: :stuck
expose :auto_devops_source?, as: :auto_devops
expose :merge_request_event?, as: :merge_request
......@@ -34,6 +33,7 @@ class PipelineEntity < Grape::Entity
expose :details do
expose :detailed_status, as: :status, with: DetailedStatusEntity
expose :ordered_stages, as: :stages, using: StageEntity
expose :duration
expose :finished_at
end
......
......@@ -28,6 +28,7 @@ module Issuable
new_params = {
project: new_entity.project, noteable: new_entity,
note: rewrite_content(new_note.note),
note_html: nil,
created_at: note.created_at,
updated_at: note.updated_at
}
......
......@@ -11,12 +11,14 @@
= f.hidden_field :user_id
.form-group.row
= f.label :user_id, class: 'col-sm-2 col-form-label'
.col-sm-2.col-form-label
= f.label :user_id
.col-sm-10
- name = "#{@abuse_report.user.name} (@#{@abuse_report.user.username})"
= text_field_tag :user_name, name, class: "form-control", readonly: true
.form-group.row
= f.label :message, class: 'col-sm-2 col-form-label'
.col-sm-2.col-form-label
= f.label :message
.col-sm-10
= f.text_area :message, class: "form-control", rows: 2, required: true, value: sanitize(@ref_url)
.form-text.text-muted
......
......@@ -2,13 +2,15 @@
= form_errors(application)
= content_tag :div, class: 'form-group row' do
= f.label :name, class: 'col-sm-2 col-form-label'
.col-sm-2.col-form-label
= f.label :name
.col-sm-10
= f.text_field :name, class: 'form-control'
= doorkeeper_errors_for application, :name
= content_tag :div, class: 'form-group row' do
= f.label :redirect_uri, class: 'col-sm-2 col-form-label'
.col-sm-2.col-form-label
= f.label :redirect_uri
.col-sm-10
= f.text_area :redirect_uri, class: 'form-control'
= doorkeeper_errors_for application, :redirect_uri
......@@ -21,14 +23,16 @@
for local tests
= content_tag :div, class: 'form-group row' do
= f.label :trusted, class: 'col-sm-2 col-form-label pt-0'
.col-sm-2.col-form-label.pt-0
= f.label :trusted
.col-sm-10
= f.check_box :trusted
%span.form-text.text-muted
Trusted applications are automatically authorized on GitLab OAuth flow.
.form-group.row
= f.label :scopes, class: 'col-sm-2 col-form-label pt-0'
.col-sm-2.col-form-label.pt-0
= f.label :scopes
.col-sm-10
= render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes
......
......@@ -10,7 +10,8 @@
= form_errors(@broadcast_message)
.form-group.row
= f.label :message, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :message
.col-sm-10
= f.text_area :message, class: "form-control js-autosize",
required: true,
......@@ -20,19 +21,23 @@
.col-sm-10.offset-sm-2
= link_to 'Customize colors', '#', class: 'js-toggle-colors-link'
.form-group.row.js-toggle-colors-container.toggle-colors.hide
= f.label :color, "Background Color", class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :color, "Background Color"
.col-sm-10
= f.color_field :color, class: "form-control"
.form-group.row.js-toggle-colors-container.toggle-colors.hide
= f.label :font, "Font Color", class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :font, "Font Color"
.col-sm-10
= f.color_field :font, class: "form-control"
.form-group.row
= f.label :starts_at, _("Starts at (UTC)"), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :starts_at, _("Starts at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :starts_at, {}, class: 'form-control form-control-inline'
.form-group.row
= f.label :ends_at, _("Ends at (UTC)"), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :ends_at, _("Ends at (UTC)")
.col-sm-10.datetime-controls
= f.datetime_select :ends_at, {}, class: 'form-control form-control-inline'
.form-actions
......
......@@ -6,7 +6,8 @@
= render_if_exists 'admin/namespace_plan', f: f
.form-group.row.group-description-holder
= f.label :avatar, _("Group avatar"), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :avatar, _("Group avatar")
.col-sm-10
= render 'shared/choose_avatar_button', f: f
......
......@@ -2,12 +2,14 @@
= form_errors(@identity)
.form-group.row
= f.label :provider, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :provider
.col-sm-10
- values = Gitlab::Auth::OAuth::Provider.providers.map { |name| ["#{Gitlab::Auth::OAuth::Provider.label_for(name)} (#{name})", name] }
= f.select :provider, values, { allow_blank: false }, class: 'form-control'
.form-group.row
= f.label :extern_uid, _("Identifier"), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :extern_uid, _("Identifier")
.col-sm-10
= f.text_field :extern_uid, class: 'form-control', required: true
......
......@@ -2,15 +2,18 @@
= form_errors(@label)
.form-group.row
= f.label :title, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :title
.col-sm-10
= f.text_field :title, class: "form-control", required: true
.form-group.row
= f.label :description, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit"
.form-group.row
= f.label :color, _("Background color"), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :color, _("Background color")
.col-sm-10
.input-group
.input-group-prepend
......
......@@ -117,7 +117,8 @@
.card-body
= form_for @project, url: transfer_admin_project_path(@project), method: :put do |f|
.form-group.row
= f.label :new_namespace_id, "Namespace", class: 'col-form-label col-sm-3'
.col-sm-3.col-form-label
= f.label :new_namespace_id, "Namespace"
.col-sm-9
.dropdown
= dropdown_toggle('Search for Namespace', { toggle: 'dropdown', field_name: 'new_namespace_id' }, { toggle_class: 'js-namespace-select large' })
......
%fieldset
%legend Access
.form-group.row
.col-sm-2.text-right
= f.label :projects_limit, class: 'col-form-label'
.col-sm-10= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
.col-sm-2.col-form-label
= f.label :projects_limit
.col-sm-10
= f.number_field :projects_limit, min: 0, max: Gitlab::Database::MAX_INT_VALUE, class: 'form-control'
.form-group.row
.col-sm-2.text-right
= f.label :can_create_group, class: 'col-form-label'
.col-sm-10= f.check_box :can_create_group
.col-sm-2.col-form-label
= f.label :can_create_group
.col-sm-10
= f.check_box :can_create_group
.form-group.row
.col-sm-2.text-right
= f.label :access_level, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :access_level
.col-sm-10
- editing_current_user = (current_user == @user)
......@@ -34,8 +36,8 @@
You cannot remove your own admin rights.
.form-group.row
.col-sm-2.text-right
= f.label :external, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :external
.hidden{ data: user_internal_regex_data }
.col-sm-10
= f.check_box :external do
......
......@@ -5,20 +5,20 @@
%fieldset
%legend Account
.form-group.row
.col-sm-2.text-right
= f.label :name, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :name
.col-sm-10
= f.text_field :name, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
.form-group.row
.col-sm-2.text-right
= f.label :username, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :username
.col-sm-10
= f.text_field :username, required: true, autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: false, class: 'form-control'
%span.help-inline * required
.form-group.row
.col-sm-2.text-right
= f.label :email, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :email
.col-sm-10
= f.text_field :email, required: true, autocomplete: 'off', class: 'form-control'
%span.help-inline * required
......@@ -27,8 +27,8 @@
%fieldset
%legend Password
.form-group.row
.col-sm-2.text-right
= f.label :password, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :password
.col-sm-10
%strong
Reset link will be generated and sent to the user.
......@@ -38,13 +38,15 @@
%fieldset
%legend Password
.form-group.row
.col-sm-2.text-right
= f.label :password, class: 'col-form-label'
.col-sm-10= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
.col-sm-2.col-form-label
= f.label :password
.col-sm-10
= f.password_field :password, disabled: f.object.force_random_password, class: 'form-control'
.form-group.row
.col-sm-2.text-right
= f.label :password_confirmation, class: 'col-form-label'
.col-sm-10= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
.col-sm-2.col-form-label
= f.label :password_confirmation
.col-sm-10
= f.password_field :password_confirmation, disabled: f.object.force_random_password, class: 'form-control'
= render partial: 'access_levels', locals: { f: f }
......@@ -55,27 +57,31 @@
%fieldset
%legend Profile
.form-group.row
.col-sm-2.text-right
= f.label :avatar, class: 'col-form-label'
.col-sm-2.col-form-label
= f.label :avatar
.col-sm-10
= f.file_field :avatar
.form-group.row
.col-sm-2.text-right
= f.label :skype, class: 'col-form-label'
.col-sm-10= f.text_field :skype, class: 'form-control'
.col-sm-2.col-form-label
= f.label :skype
.col-sm-10
= f.text_field :skype, class: 'form-control'
.form-group.row
.col-sm-2.text-right
= f.label :linkedin, class: 'col-form-label'
.col-sm-10= f.text_field :linkedin, class: 'form-control'
.col-sm-2.col-form-label
= f.label :linkedin
.col-sm-10
= f.text_field :linkedin, class: 'form-control'
.form-group.row
.col-sm-2.text-right
= f.label :twitter, class: 'col-form-label'
.col-sm-10= f.text_field :twitter, class: 'form-control'
.col-sm-2.col-form-label
= f.label :twitter
.col-sm-10
= f.text_field :twitter, class: 'form-control'
.form-group.row
.col-sm-2.text-right
= f.label :website_url, 'Website', class: 'col-form-label'
.col-sm-10= f.text_field :website_url, class: 'form-control'
.col-sm-2.col-form-label
= f.label :website_url
.col-sm-10
= f.text_field :website_url, class: 'form-control'
= render_if_exists 'admin/users/admin_notes', f: f
......
......@@ -59,7 +59,7 @@
.append-right-default
= s_("CiVariable|Masked")
%button{ type: 'button',
class: "js-project-feature-toggle project-feature-toggle #{'is-checked' if is_masked}",
class: "js-project-feature-toggle project-feature-toggle qa-variable-masked #{'is-checked' if is_masked}",
"aria-label": s_("CiVariable|Toggle masked") }
%input{ type: "hidden",
class: 'js-ci-variable-input-masked js-project-feature-toggle-input',
......
......@@ -5,5 +5,17 @@
.hidden.js-cluster-creating.bs-callout.bs-callout-info{ role: 'alert' }
= s_('ClusterIntegration|Kubernetes cluster is being created on Google Kubernetes Engine...')
.hidden.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
.col-11
= s_('ClusterIntegration|Your cluster API is unreachable. Please ensure your API URL is correct.')
.col-1.p-0
%button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
.hidden.js-cluster-authentication-failure.row.js-cluster-api-unreachable.bs-callout.bs-callout-warning{ role: 'alert' }
.col-11
= s_('ClusterIntegration|There was a problem authenticating with your cluster. Please ensure your CA Certificate and Token are valid.')
.col-1.p-0
%button.js-close-banner.close.cluster-application-banner-close.h-100.m-0= "×"
.hidden.js-cluster-success.bs-callout.bs-callout-success{ role: 'alert' }
= s_("ClusterIntegration|Kubernetes cluster was successfully created on Google Kubernetes Engine. Refresh the page to see Kubernetes cluster's details")
......@@ -24,7 +24,8 @@
help_path: help_page_path('user/project/clusters/index.md', anchor: 'installing-applications'),
ingress_help_path: help_page_path('user/project/clusters/index.md', anchor: 'getting-the-external-endpoint'),
ingress_dns_help_path: help_page_path('user/project/clusters/index.md', anchor: 'manually-determining-the-external-endpoint'),
manage_prometheus_path: manage_prometheus_path } }
manage_prometheus_path: manage_prometheus_path,
cluster_id: @cluster.id } }
.js-cluster-application-notice
.flash-container
......
......@@ -4,24 +4,24 @@
.devise-errors
= render "devise/shared/error_messages", resource: resource
.name.form-group
= f.label :name, 'Full name', class: 'label-bold'
= f.label :name, _('Full name'), class: 'label-bold'
= f.text_field :name, class: "form-control top qa-new-user-name js-block-emoji", required: true, title: _("This field is required.")
.username.form-group
= f.label :username, class: 'label-bold'
= f.text_field :username, class: "form-control middle qa-new-user-username js-block-emoji", pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, required: true, title: _("Please create a username with only alphanumeric characters.")
%p.validation-error.hide Username is already taken.
%p.validation-success.hide Username is available.
%p.validation-pending.hide Checking username availability...
%p.validation-error.hide= _('Username is already taken.')
%p.validation-success.hide= _('Username is available.')
%p.validation-pending.hide= _('Checking username availability...')
.form-group
= f.label :email, class: 'label-bold'
= f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: "Please provide a valid email address."
= f.email_field :email, class: "form-control middle qa-new-user-email", required: true, title: _("Please provide a valid email address.")
.form-group
= f.label :email_confirmation, class: 'label-bold'
= f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: "Please retype the email address."
= f.email_field :email_confirmation, class: "form-control middle qa-new-user-email-confirmation", required: true, title: _("Please retype the email address.")
.form-group.append-bottom-20#password-strength
= f.label :password, class: 'label-bold'
= f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: "Minimum length is #{@minimum_password_length} characters."
%p.gl-field-hint.text-secondary Minimum length is #{@minimum_password_length} characters
= f.password_field :password, class: "form-control bottom qa-new-user-password", required: true, pattern: ".{#{@minimum_password_length},}", title: _("Minimum length is %{minimum_password_length} characters.") % { minimum_password_length: @minimum_password_length }
%p.gl-field-hint.text-secondary= _('Minimum length is %{minimum_password_length} characters') % { minimum_password_length: @minimum_password_length }
- if Gitlab::CurrentSettings.current_application_settings.enforce_terms?
.form-group
= check_box_tag :terms_opt_in, '1', false, required: true, class: 'qa-new-user-accept-terms'
......@@ -34,4 +34,4 @@
- if Gitlab::Recaptcha.enabled?
= recaptcha_tags
.submit-container
= f.submit "Register", class: "btn-register btn qa-new-user-register-button"
= f.submit _("Register"), class: "btn-register btn qa-new-user-register-button"
.form-group
= f.label :create_chat_team, class: 'col-form-label' do
%span.mattermost-icon
= custom_icon('icon_mattermost')
Mattermost
.col-sm-2.col-form-label
= f.label :create_chat_team do
%span.mattermost-icon
= custom_icon('icon_mattermost')
Mattermost
.col-sm-10
.form-check.js-toggle-container
.js-toggle-button.form-check-input= f.check_box(:create_chat_team, { checked: true }, true, false)
......
.form-group.row
= f.label :lfs_enabled, 'Large File Storage', class: 'col-form-label col-sm-2 pt-0'
.col-sm-2.col-form-label.pt-0
= f.label :lfs_enabled, 'Large File Storage'
.col-sm-10
.form-check
= f.check_box :lfs_enabled, checked: @group.lfs_enabled?, class: 'form-check-input'
......@@ -10,12 +11,14 @@
%br/
%span.descr This setting can be overridden in each project.
.form-group.row
= f.label s_('ProjectCreationLevel|Allowed to create projects'), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label s_('ProjectCreationLevel|Allowed to create projects')
.col-sm-10
= f.select :project_creation_level, options_for_select(::Gitlab::Access.project_creation_options, @group.project_creation_level), {}, class: 'form-control'
.form-group.row
= f.label :require_two_factor_authentication, 'Two-factor authentication', class: 'col-form-label col-sm-2 pt-0'
.col-sm-2.col-form-label.pt-0
= f.label :require_two_factor_authentication, 'Two-factor authentication'
.col-sm-10
.form-check
= f.check_box :require_two_factor_authentication, class: 'form-check-input'
......
%h5.prepend-top-0
History of authentications
= _('History of authentications')
%ul.content-list
- events.each do |event|
%li
%span.description
= audit_icon(event.details[:with], class: "append-right-5")
Signed in with
= event.details[:with]
authentication
= _('Signed in with %{authentication} authentication') % { authentication: event.details[:with]}
%span.float-right= time_ago_with_tooltip(event.created_at)
= paginate events, theme: "gitlab"
- page_title "Authentication log"
- page_title _('Authentication log')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
......@@ -6,6 +6,6 @@
%h4.prepend-top-0
= page_title
%p
This is a security log of important events involving your account.
= _('This is a security log of important events involving your account.')
.col-lg-8
= render 'event_table', events: @events
......@@ -21,7 +21,7 @@
- if chat_name.last_used_at
= time_ago_with_tooltip(chat_name.last_used_at)
- else
Never
= _('Never')
%td
= link_to 'Remove', profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: 'Are you sure you want to revoke this nickname?' }
= link_to _('Remove'), profile_chat_name_path(chat_name), method: :delete, class: 'btn btn-danger float-right', data: { confirm: _('Are you sure you want to revoke this nickname?') }
- page_title 'Chat'
- page_title _('Chat')
- @content_class = "limit-container-width" unless fluid_layout
.row.prepend-top-default
......@@ -6,7 +6,7 @@
%h4.prepend-top-0
= page_title
%p
You can see your Chat accounts.
= _('You can see your chat accounts.')
.col-lg-8
%h5 Active chat names (#{@chat_names.size})
......@@ -16,15 +16,15 @@
%table.table.chat-names
%thead
%tr
%th Project
%th Service
%th Team domain
%th Nickname
%th Last used
%th= _('Project')
%th= _('Service')
%th= _('Team domain')
%th= _('Nickname')
%th= _('Last used')
%th
%tbody
= render @chat_names
- else
.settings-message.text-center
You don't have any active chat names.
= _("You don't have any active chat names.")
- page_title "Notifications"
- page_title _('Notifications')
- @content_class = "limit-container-width" unless fluid_layout
%div
......@@ -14,12 +14,12 @@
%h4.prepend-top-0
= page_title
%p
You can specify notification level per group or per project.
= _('You can specify notification level per group or per project.')
%p
By default, all projects and groups will use the global notifications setting.
= _('By default, all projects and groups will use the global notifications setting.')
.col-lg-8
%h5.prepend-top-0
Global notification settings
= _('Global notification settings')
= form_for @user, url: profile_notifications_path, method: :put, html: { class: 'update-notifications prepend-top-default' } do |f|
= render_if_exists 'profiles/notifications/email_settings', form: f
......@@ -35,19 +35,19 @@
= form_for @user, url: profile_notifications_path, method: :put do |f|
%label{ for: 'user_notified_of_own_activity' }
= f.check_box :notified_of_own_activity
%span Receive notifications about your own activity
%span= _('Receive notifications about your own activity')
%hr
%h5
Groups (#{@group_notifications.count})
= _('Groups (%{count})') % { count: @group_notifications.count }
%div
%ul.bordered-list
- @group_notifications.each do |setting|
= render 'group_settings', setting: setting, group: setting.source
%h5
Projects (#{@project_notifications.count})
= _('Projects (%{count})') % { count: @project_notifications.count }
%p.account-well
To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.
= _('To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there.')
.append-bottom-default
%ul.bordered-list
- @project_notifications.each do |setting|
......
......@@ -13,13 +13,18 @@
- unless @user.password_automatically_set?
.form-group.row
= f.label :current_password, _('Current password'), class: 'col-form-label col-sm-2'
.col-sm-10= f.password_field :current_password, required: true, class: 'form-control'
.col-sm-2.col-form-label
= f.label :current_password, _('Current password')
.col-sm-10
= f.password_field :current_password, required: true, class: 'form-control'
.form-group.row
= f.label :password, _('New Password'), class: 'col-form-label col-sm-2'
.col-sm-10= f.password_field :password, required: true, class: 'form-control'
.col-sm-2.col-form-label
= f.label :password, _('New Password')
.col-sm-10
= f.password_field :password, required: true, class: 'form-control'
.form-group.row
= f.label :password_confirmation, _('Password confirmation'), class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :password_confirmation, _('Password confirmation')
.col-sm-10
= f.password_field :password_confirmation, required: true, class: 'form-control'
.form-actions
......
- page_title 'Preferences'
- page_title _('Preferences')
- @content_class = "limit-container-width" unless fluid_layout
= form_for @user, url: profile_preferences_path, remote: true, method: :put, html: { class: 'row prepend-top-default js-preferences-form' } do |f|
.col-lg-4.application-theme
%h4.prepend-top-0
= s_('Preferences|Navigation theme')
%p Customize the appearance of the application header and navigation sidebar.
%p= _('Customize the appearance of the application header and navigation sidebar.')
.col-lg-8.application-theme
- Gitlab::Themes.each do |theme|
= label_tag do
......@@ -18,11 +18,11 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Syntax highlighting theme
= _('Syntax highlighting theme')
%p
This setting allows you to customize the appearance of the syntax.
= _('This setting allows you to customize the appearance of the syntax.')
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'syntax-highlighting-theme'), target: '_blank'
.col-lg-8.syntax-theme
- Gitlab::ColorSchemes.each do |scheme|
= label_tag do
......@@ -35,31 +35,31 @@
.col-lg-4.profile-settings-sidebar
%h4.prepend-top-0
Behavior
= _('Behavior')
%p
This setting allows you to customize the behavior of the system layout and default views.
= _('This setting allows you to customize the behavior of the system layout and default views.')
= succeed '.' do
= link_to 'Learn more', help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
= link_to _('Learn more'), help_page_path('user/profile/preferences', anchor: 'behavior'), target: '_blank'
.col-lg-8
.form-group
= f.label :layout, class: 'label-bold' do
Layout width
= _('Layout width')
= f.select :layout, layout_choices, {}, class: 'form-control'
.form-text.text-muted
Choose between fixed (max. 1280px) and fluid (100%) application layout.
= _('Choose between fixed (max. 1280px) and fluid (100%%) application layout.')
.form-group
= f.label :dashboard, class: 'label-bold' do
Default dashboard
= _('Default dashboard')
= f.select :dashboard, dashboard_choices, {}, class: 'form-control'
= render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific
.form-group
= f.label :project_view, class: 'label-bold' do
Project overview content
= _('Project overview content')
= f.select :project_view, project_view_choices, {}, class: 'form-control'
.form-text.text-muted
Choose what content you want to see on a project’s overview page.
= _('Choose what content you want to see on a project’s overview page.')
.col-sm-12
%hr
......
......@@ -18,7 +18,7 @@
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout)
- if vue_file_list
#js-tree-list{ data: { project_path: @project.full_path, ref: ref } }
#js-tree-list{ data: { project_path: @project.full_path, full_name: @project.name_with_namespace, ref: ref } }
- if @tree.readme
= render "projects/tree/readme", readme: @tree.readme
- else
......
......@@ -16,7 +16,7 @@
%th Runner
%th Stage
%th Name
%th
%th Timing
%th Coverage
%th
......
......@@ -5,22 +5,22 @@
%p= msg
.form-group.row
= f.label :domain, class: 'col-form-label col-sm-2' do
= _("Domain")
.col-sm-2.col-form-label
= f.label :domain, _("Domain")
.col-sm-10
= f.text_field :domain, required: true, autocomplete: 'off', class: 'form-control', disabled: @domain.persisted?
- if Gitlab.config.pages.external_https
.form-group.row
= f.label :certificate, class: 'col-form-label col-sm-2' do
= _("Certificate (PEM)")
.col-sm-2.col-form-label
= f.label :certificate, _("Certificate (PEM)")
.col-sm-10
= f.text_area :certificate, rows: 5, class: 'form-control'
%span.help-inline= _("Upload a certificate for your domain with all intermediates")
.form-group.row
= f.label :key, class: 'col-form-label col-sm-2' do
= _("Key (PEM)")
.col-sm-2.col-form-label
= f.label :key, _("Key (PEM)")
.col-sm-10
= f.text_area :key, rows: 5, class: 'form-control'
%span.help-inline= _("Upload a private key for your certificate")
......
- page_title _("Members")
- can_admin_project_members = can?(current_user, :admin_project_member, @project)
.row.prepend-top-default
.col-lg-12
%h4
= _("Project members")
- if can?(current_user, :admin_project_member, @project)
%p
= _("You can invite a new member to <strong>%{project_name}</strong> or invite another group.").html_safe % { project_name: sanitize(@project.name, tags: []) }
- else
%p
= _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe
- if project_can_be_shared?
%h4
= _("Project members")
- if can_admin_project_members
%p= share_project_description(@project)
- else
%p
= _("Members can be added by project <i>Maintainers</i> or <i>Owners</i>").html_safe
.light
- if can?(current_user, :admin_project_member, @project)
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
- if @project.allowed_to_share_with_group?
- if can_admin_project_members && project_can_be_shared?
- if !membership_locked? && @project.allowed_to_share_with_group?
%ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' }
%li.nav-tab{ role: 'presentation' }
%a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite member")
%li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) }
%a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' }= _("Invite group")
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
.tab-content.gitlab-tab-content
.tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' }
= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
.tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) }
= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
- elsif !membership_locked?
.invite-member= render 'projects/project_members/new_project_member', tab_title: _('Invite member')
- elsif @project.allowed_to_share_with_group?
.invite-group= render 'projects/project_members/new_project_group', tab_title: _('Invite group')
= render 'shared/members/requests', membership_source: @project, requesters: @requesters
.clearfix
......
......@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_branch)
.form-group.row
= f.label :name, class: 'col-md-2 text-right' do
Branch:
.col-md-2.text-right
= f.label :name, 'Branch:'
.col-md-10
= render partial: "projects/protected_branches/shared/dropdown", locals: { f: f }
.form-text.text-muted
......
......@@ -6,8 +6,8 @@
.card-body
= form_errors(@protected_tag)
.form-group.row
= f.label :name, class: 'col-md-2 text-right' do
Tag:
.col-md-2.text-right
= f.label :name, 'Tag:'
.col-md-10.protected-tags-dropdown
= render partial: "projects/protected_tags/shared/dropdown", locals: { f: f }
.form-text.text-muted
......
%tr.tag
%td
= escape_once(tag.name)
= clipboard_button(text: "docker pull #{tag.location}")
= clipboard_button(text: "#{tag.location}")
%td
- if tag.revision
%span.has-tooltip{ title: "#{tag.revision}" }
......
......@@ -2,10 +2,12 @@
- setting = error_tracking_setting
%section.settings.expanded.no-animate
%section.settings.no-animate.js-error-tracking-settings
.settings-header
%h4
= _('Error Tracking')
%button.btn.js-settings-toggle{ type: 'button' }
= _('Expand')
%p
= _('To link Sentry to GitLab, enter your Sentry URL and Auth Token.')
= link_to _('More information'), help_page_path('user/project/operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer'
......
......@@ -3,6 +3,6 @@
- breadcrumb_title _('Operations Settings')
= render_if_exists 'projects/settings/operations/incidents'
= render 'projects/settings/operations/error_tracking', expanded: true
= render 'projects/settings/operations/error_tracking'
= render 'projects/settings/operations/external_dashboard'
= render_if_exists 'projects/settings/operations/tracing'
......@@ -9,7 +9,7 @@
.modal-body
%p
%strong= label.name
%span will be permanently deleted from #{label.subject.name}. This cannot be undone.
%span will be permanently deleted from #{label.subject_name}. This cannot be undone.
.modal-footer
%a{ href: '#', data: { dismiss: 'modal' }, class: 'btn btn-default' } Cancel
......
......@@ -30,7 +30,7 @@
= sprite_icon('ellipsis_v')
.dropdown-menu.dropdown-open-left
%ul
- if label.is_a?(ProjectLabel) && label.project.group && can?(current_user, :admin_label, label.project.group)
- if label.project_label? && label.project.group && can?(current_user, :admin_label, label.project.group)
%li
%button.js-promote-project-label-button.btn.btn-transparent.btn-action{ disabled: true, type: 'button',
data: { url: promote_project_label_path(label.project, label),
......
......@@ -2,17 +2,20 @@
= form_errors(@label)
.form-group.row
= f.label :title, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :title
.col-sm-10
= f.text_field :title, class: "form-control js-label-title qa-label-title", required: true, autofocus: true
= render_if_exists 'shared/labels/create_label_help_text'
.form-group.row
= f.label :description, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :description
.col-sm-10
= f.text_field :description, class: "form-control js-quick-submit qa-label-description"
.form-group.row
= f.label :color, "Background color", class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :color, "Background color"
.col-sm-10
.input-group
.input-group-prepend
......
......@@ -15,7 +15,7 @@
= icon('caret-down')
.sr-only Toggle dropdown
- else
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting"), class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= icon("bell", class: "js-notification-loading")
= notification_title(notification_setting.level)
= icon("caret-down")
......
......@@ -16,7 +16,7 @@
= sprite_icon("arrow-down", css_class: "icon mr-0")
.sr-only Toggle dropdown
- else
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: "Notification setting - #{notification_title(notification_setting.level)}", class: "#{btn_class}", "aria-label" => "Notification setting: #{notification_title(notification_setting.level)}", data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
%button.dropdown-new.btn.btn-default.has-tooltip.notifications-btn#notifications-button{ type: "button", title: _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, class: "#{btn_class}", "aria-label" => _("Notification setting - %{notification_title}") % { notification_title: notification_title(notification_setting.level) }, data: { container: "body", placement: 'top', toggle: "dropdown", target: notifications_menu_identifier("dropdown", notification_setting), flip: "false" } }
= notification_setting_icon(notification_setting)
%span.js-notification-loading.fa.hidden
= sprite_icon("arrow-down", css_class: "icon")
......
......@@ -8,5 +8,5 @@
%li.divider
%li
%a.update-notification{ href: "#", role: "button", class: ("is-active" if notification_setting.custom?), data: { toggle: "modal", target: "#" + notifications_menu_identifier("modal", notification_setting), notification_level: "custom", notification_title: "Custom" } }
%strong.dropdown-menu-inner-title Custom
%strong.dropdown-menu-inner-title= s_('NotificationSetting|Custom')
%span.dropdown-menu-inner-content= notification_description("custom")
......@@ -7,7 +7,8 @@
= form_errors(@snippet)
.form-group.row
= f.label :title, class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :title
.col-sm-10
= f.text_field :title, class: 'form-control qa-snippet-title', required: true, autofocus: true
......@@ -17,7 +18,8 @@
.file-editor
.form-group.row
= f.label :file_name, "File", class: 'col-form-label col-sm-2'
.col-sm-2.col-form-label
= f.label :file_name, "File"
.col-sm-10
.file-holder.snippet
.js-file-title.file-title
......
---
title: Fix proxy support in Container Scanning
merge_request: 27246
author:
type: fixed
---
title: Remove support for using Geo with an installation from source
merge_request: 28737
author:
type: other
---
title: Remove `docker pull` prefix when copying a tag from the registry
merge_request: 28757
author: Benedikt Franke
type: changed
---
title: Show data on Cycle Analytics page when value is less than a second
merge_request: 28507
author:
type: fixed
---
title: Removes duplicated members from api/projects/:id/members/all
merge_request: 24005
author: Jacopo Beschi @jacopo-beschi
type: changed
---
title: Validate Kubernetes credentials at cluster creation
merge_request: 27403
author:
type: added
---
title: Fix col-sm-* in forms to keep layout
merge_request: 24885
author: Takuya Noguchi
type: fixed
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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