Commit a5217676 authored by Dennis Tang's avatar Dennis Tang

Merge remote-tracking branch 'origin/master' into 43446-new-cluster-page-tabs

# Conflicts:
#	doc/user/project/clusters/index.md
parents fcb7b31c fe0ebf76
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git-2.17-chrome-65.0-node-8.x-yarn-1.2-postgresql-9.6" image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.4.4-golang-1.9-git-2.17-chrome-65.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner .dedicated-runner: &dedicated-runner
retry: 1 retry: 1
...@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git ...@@ -6,7 +6,7 @@ image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.7-golang-1.9-git
- gitlab-org - gitlab-org
.default-cache: &default-cache .default-cache: &default-cache
key: "ruby-2.3.7-debian-stretch-with-yarn" key: "ruby-2.4.4-debian-stretch-with-yarn"
paths: paths:
- vendor/ruby - vendor/ruby
- .yarn-cache/ - .yarn-cache/
...@@ -550,7 +550,7 @@ static-analysis: ...@@ -550,7 +550,7 @@ static-analysis:
script: script:
- scripts/static-analysis - scripts/static-analysis
cache: cache:
key: "ruby-2.3.7-debian-stretch-with-yarn-and-rubocop" key: "ruby-2.4.4-debian-stretch-with-yarn-and-rubocop"
paths: paths:
- vendor/ruby - vendor/ruby
- .yarn-cache/ - .yarn-cache/
......
...@@ -540,7 +540,7 @@ GEM ...@@ -540,7 +540,7 @@ GEM
omniauth-github (1.3.0) omniauth-github (1.3.0)
omniauth (~> 1.5) omniauth (~> 1.5)
omniauth-oauth2 (>= 1.4.0, < 2.0) omniauth-oauth2 (>= 1.4.0, < 2.0)
omniauth-gitlab (1.0.2) omniauth-gitlab (1.0.3)
omniauth (~> 1.0) omniauth (~> 1.0)
omniauth-oauth2 (~> 1.0) omniauth-oauth2 (~> 1.0)
omniauth-google-oauth2 (0.5.3) omniauth-google-oauth2 (0.5.3)
......
...@@ -11,6 +11,7 @@ const Api = { ...@@ -11,6 +11,7 @@ const Api = {
projectPath: '/api/:version/projects/:id', projectPath: '/api/:version/projects/:id',
projectLabelsPath: '/:namespace_path/:project_path/labels', projectLabelsPath: '/:namespace_path/:project_path/labels',
mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', mergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid',
mergeRequestsPath: '/api/:version/merge_requests',
mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes', mergeRequestChangesPath: '/api/:version/projects/:id/merge_requests/:mrid/changes',
mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions', mergeRequestVersionsPath: '/api/:version/projects/:id/merge_requests/:mrid/versions',
groupLabelsPath: '/groups/:namespace_path/-/labels', groupLabelsPath: '/groups/:namespace_path/-/labels',
...@@ -24,8 +25,6 @@ const Api = { ...@@ -24,8 +25,6 @@ const Api = {
commitPipelinesPath: '/:project_id/commit/:sha/pipelines', commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch', branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches', createBranchPath: '/api/:version/projects/:id/repository/branches',
pipelinesPath: '/api/:version/projects/:id/pipelines',
pipelineJobsPath: '/api/:version/projects/:id/pipelines/:pipeline_id/jobs',
group(groupId, callback) { group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -109,6 +108,12 @@ const Api = { ...@@ -109,6 +108,12 @@ const Api = {
return axios.get(url); return axios.get(url);
}, },
mergeRequests(params = {}) {
const url = Api.buildUrl(Api.mergeRequestsPath);
return axios.get(url, { params });
},
mergeRequestChanges(projectPath, mergeRequestId) { mergeRequestChanges(projectPath, mergeRequestId) {
const url = Api.buildUrl(Api.mergeRequestChangesPath) const url = Api.buildUrl(Api.mergeRequestChangesPath)
.replace(':id', encodeURIComponent(projectPath)) .replace(':id', encodeURIComponent(projectPath))
...@@ -238,20 +243,6 @@ const Api = { ...@@ -238,20 +243,6 @@ const Api = {
}); });
}, },
pipelines(projectPath, params = {}) {
const url = Api.buildUrl(this.pipelinesPath).replace(':id', encodeURIComponent(projectPath));
return axios.get(url, { params });
},
pipelineJobs(projectPath, pipelineId, params = {}) {
const url = Api.buildUrl(this.pipelineJobsPath)
.replace(':id', encodeURIComponent(projectPath))
.replace(':pipeline_id', pipelineId);
return axios.get(url, { params });
},
buildUrl(url) { buildUrl(url) {
let urlRoot = ''; let urlRoot = '';
if (gon.relative_url_root != null) { if (gon.relative_url_root != null) {
......
/* eslint-disable comma-dangle, space-before-function-paren, one-var */ /* eslint-disable comma-dangle, space-before-function-paren, one-var */
import $ from 'jquery'; import $ from 'jquery';
import Sortable from 'vendor/Sortable'; import Sortable from 'sortablejs';
import Vue from 'vue'; import Vue from 'vue';
import AccessorUtilities from '../../lib/utils/accessor'; import AccessorUtilities from '../../lib/utils/accessor';
import boardList from './board_list.vue'; import boardList from './board_list.vue';
......
<script> <script>
import Sortable from 'vendor/Sortable'; import Sortable from 'sortablejs';
import boardNewIssue from './board_new_issue.vue'; import boardNewIssue from './board_new_issue.vue';
import boardCard from './board_card.vue'; import boardCard from './board_card.vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
......
...@@ -31,6 +31,7 @@ export default class Clusters { ...@@ -31,6 +31,7 @@ export default class Clusters {
installHelmPath, installHelmPath,
installIngressPath, installIngressPath,
installRunnerPath, installRunnerPath,
installJupyterPath,
installPrometheusPath, installPrometheusPath,
managePrometheusPath, managePrometheusPath,
clusterStatus, clusterStatus,
...@@ -51,6 +52,7 @@ export default class Clusters { ...@@ -51,6 +52,7 @@ export default class Clusters {
installIngressEndpoint: installIngressPath, installIngressEndpoint: installIngressPath,
installRunnerEndpoint: installRunnerPath, installRunnerEndpoint: installRunnerPath,
installPrometheusEndpoint: installPrometheusPath, installPrometheusEndpoint: installPrometheusPath,
installJupyterEndpoint: installJupyterPath,
}); });
this.installApplication = this.installApplication.bind(this); this.installApplication = this.installApplication.bind(this);
...@@ -209,11 +211,12 @@ export default class Clusters { ...@@ -209,11 +211,12 @@ export default class Clusters {
} }
} }
installApplication(appId) { installApplication(data) {
const appId = data.id;
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING); this.store.updateAppProperty(appId, 'requestStatus', REQUEST_LOADING);
this.store.updateAppProperty(appId, 'requestReason', null); this.store.updateAppProperty(appId, 'requestReason', null);
this.service.installApplication(appId) this.service.installApplication(appId, data.params)
.then(() => { .then(() => {
this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS); this.store.updateAppProperty(appId, 'requestStatus', REQUEST_SUCCESS);
}) })
......
...@@ -52,6 +52,11 @@ ...@@ -52,6 +52,11 @@
type: String, type: String,
required: false, required: false,
}, },
installApplicationRequestParams: {
type: Object,
required: false,
default: () => ({}),
},
}, },
computed: { computed: {
rowJsClass() { rowJsClass() {
...@@ -109,7 +114,10 @@ ...@@ -109,7 +114,10 @@
}, },
methods: { methods: {
installClicked() { installClicked() {
eventHub.$emit('installApplication', this.id); eventHub.$emit('installApplication', {
id: this.id,
params: this.installApplicationRequestParams,
});
}, },
}, },
}; };
......
...@@ -121,6 +121,12 @@ export default { ...@@ -121,6 +121,12 @@ export default {
false, false,
); );
}, },
jupyterInstalled() {
return this.applications.jupyter.status === APPLICATION_INSTALLED;
},
jupyterHostname() {
return this.applications.jupyter.hostname;
},
}, },
}; };
</script> </script>
...@@ -278,11 +284,67 @@ export default { ...@@ -278,11 +284,67 @@ export default {
applications to production.`) }} applications to production.`) }}
</div> </div>
</application-row> </application-row>
<application-row
id="jupyter"
:title="applications.jupyter.title"
title-link="https://jupyterhub.readthedocs.io/en/stable/"
:status="applications.jupyter.status"
:status-reason="applications.jupyter.statusReason"
:request-status="applications.jupyter.requestStatus"
:request-reason="applications.jupyter.requestReason"
:install-application-request-params="{ hostname: applications.jupyter.hostname }"
>
<div slot="description">
<p>
{{ s__(`ClusterIntegration|JupyterHub, a multi-user Hub, spawns,
manages, and proxies multiple instances of the single-user
Jupyter notebook server. JupyterHub can be used to serve
notebooks to a class of students, a corporate data science group,
or a scientific research group.`) }}
</p>
<template v-if="ingressExternalIp">
<div class="form-group">
<label for="jupyter-hostname">
{{ s__('ClusterIntegration|Jupyter Hostname') }}
</label>
<div class="input-group">
<input
type="text"
class="form-control js-hostname"
v-model="applications.jupyter.hostname"
:readonly="jupyterInstalled"
/>
<span
class="input-group-btn"
>
<clipboard-button
:text="jupyterHostname"
:title="s__('ClusterIntegration|Copy Jupyter Hostname to clipboard')"
class="js-clipboard-btn"
/>
</span>
</div>
</div>
<p v-if="ingressInstalled">
{{ s__(`ClusterIntegration|Replace this with your own hostname if you want.
If you do so, point hostname to Ingress IP Address from above.`) }}
<a
:href="ingressDnsHelpPath"
target="_blank"
rel="noopener noreferrer"
>
{{ __('More information') }}
</a>
</p>
</template>
</div>
</application-row>
<!-- <!--
NOTE: Don't forget to update `clusters.scss` NOTE: Don't forget to update `clusters.scss`
min-height for this block and uncomment `application_spec` tests min-height for this block and uncomment `application_spec` tests
--> -->
<!-- Add GitLab Runner row, all other plumbing is complete -->
</div> </div>
</div> </div>
</section> </section>
......
...@@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading'; ...@@ -11,3 +11,4 @@ export const REQUEST_LOADING = 'request-loading';
export const REQUEST_SUCCESS = 'request-success'; export const REQUEST_SUCCESS = 'request-success';
export const REQUEST_FAILURE = 'request-failure'; export const REQUEST_FAILURE = 'request-failure';
export const INGRESS = 'ingress'; export const INGRESS = 'ingress';
export const JUPYTER = 'jupyter';
...@@ -8,6 +8,7 @@ export default class ClusterService { ...@@ -8,6 +8,7 @@ export default class ClusterService {
ingress: this.options.installIngressEndpoint, ingress: this.options.installIngressEndpoint,
runner: this.options.installRunnerEndpoint, runner: this.options.installRunnerEndpoint,
prometheus: this.options.installPrometheusEndpoint, prometheus: this.options.installPrometheusEndpoint,
jupyter: this.options.installJupyterEndpoint,
}; };
} }
...@@ -15,8 +16,8 @@ export default class ClusterService { ...@@ -15,8 +16,8 @@ export default class ClusterService {
return axios.get(this.options.endpoint); return axios.get(this.options.endpoint);
} }
installApplication(appId) { installApplication(appId, params) {
return axios.post(this.appInstallEndpointMap[appId]); return axios.post(this.appInstallEndpointMap[appId], params);
} }
static updateCluster(endpoint, data) { static updateCluster(endpoint, data) {
......
import { s__ } from '../../locale'; import { s__ } from '../../locale';
import { INGRESS } from '../constants'; import { INGRESS, JUPYTER } from '../constants';
export default class ClusterStore { export default class ClusterStore {
constructor() { constructor() {
...@@ -38,6 +38,14 @@ export default class ClusterStore { ...@@ -38,6 +38,14 @@ export default class ClusterStore {
requestStatus: null, requestStatus: null,
requestReason: null, requestReason: null,
}, },
jupyter: {
title: s__('ClusterIntegration|JupyterHub'),
status: null,
statusReason: null,
requestStatus: null,
requestReason: null,
hostname: null,
},
}, },
}; };
} }
...@@ -83,6 +91,12 @@ export default class ClusterStore { ...@@ -83,6 +91,12 @@ export default class ClusterStore {
if (appId === INGRESS) { if (appId === INGRESS) {
this.state.applications.ingress.externalIp = serverAppEntry.external_ip; this.state.applications.ingress.externalIp = serverAppEntry.external_ip;
} else if (appId === JUPYTER) {
this.state.applications.jupyter.hostname =
serverAppEntry.hostname ||
(this.state.applications.ingress.externalIp
? `jupyter.${this.state.applications.ingress.externalIp}.xip.io`
: '');
} }
}); });
} }
......
...@@ -6,6 +6,7 @@ import RepoTabs from './repo_tabs.vue'; ...@@ -6,6 +6,7 @@ import RepoTabs from './repo_tabs.vue';
import IdeStatusBar from './ide_status_bar.vue'; import IdeStatusBar from './ide_status_bar.vue';
import RepoEditor from './repo_editor.vue'; import RepoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue'; import FindFile from './file_finder/index.vue';
import RightPane from './panes/right.vue';
const originalStopCallback = Mousetrap.stopCallback; const originalStopCallback = Mousetrap.stopCallback;
...@@ -16,6 +17,7 @@ export default { ...@@ -16,6 +17,7 @@ export default {
IdeStatusBar, IdeStatusBar,
RepoEditor, RepoEditor,
FindFile, FindFile,
RightPane,
}, },
computed: { computed: {
...mapState([ ...mapState([
...@@ -25,6 +27,7 @@ export default { ...@@ -25,6 +27,7 @@ export default {
'currentMergeRequestId', 'currentMergeRequestId',
'fileFindVisible', 'fileFindVisible',
'emptyStateSvgPath', 'emptyStateSvgPath',
'currentProjectId',
]), ]),
...mapGetters(['activeFile', 'hasChanges']), ...mapGetters(['activeFile', 'hasChanges']),
}, },
...@@ -122,6 +125,9 @@ export default { ...@@ -122,6 +125,9 @@ export default {
</div> </div>
</template> </template>
</div> </div>
<right-pane
v-if="currentProjectId"
/>
</div> </div>
<ide-status-bar :file="activeFile"/> <ide-status-bar :file="activeFile"/>
</article> </article>
......
...@@ -31,6 +31,7 @@ export default { ...@@ -31,6 +31,7 @@ export default {
computed: { computed: {
...mapState(['currentBranchId', 'currentProjectId']), ...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit']), ...mapGetters(['currentProject', 'lastCommit']),
...mapState('pipelines', ['latestPipeline']),
}, },
watch: { watch: {
lastCommit() { lastCommit() {
...@@ -51,14 +52,14 @@ export default { ...@@ -51,14 +52,14 @@ export default {
} }
}, },
methods: { methods: {
...mapActions(['pipelinePoll', 'stopPipelinePolling']), ...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() { startTimer() {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.commitAgeUpdate(); this.commitAgeUpdate();
}, 1000); }, 1000);
}, },
initPipelinePolling() { initPipelinePolling() {
this.pipelinePoll(); this.fetchLatestPipeline();
this.isPollingInitialized = true; this.isPollingInitialized = true;
}, },
commitAgeUpdate() { commitAgeUpdate() {
...@@ -81,18 +82,18 @@ export default { ...@@ -81,18 +82,18 @@ export default {
> >
<span <span
class="ide-status-pipeline" class="ide-status-pipeline"
v-if="lastCommit.pipeline && lastCommit.pipeline.details" v-if="latestPipeline && latestPipeline.details"
> >
<ci-icon <ci-icon
:status="lastCommit.pipeline.details.status" :status="latestPipeline.details.status"
v-tooltip v-tooltip
:title="lastCommit.pipeline.details.status.text" :title="latestPipeline.details.status.text"
/> />
Pipeline Pipeline
<a <a
class="monospace" class="monospace"
:href="lastCommit.pipeline.details.status.details_path">#{{ lastCommit.pipeline.id }}</a> :href="latestPipeline.details.status.details_path">#{{ latestPipeline.id }}</a>
{{ lastCommit.pipeline.details.status.text }} {{ latestPipeline.details.status.text }}
for for
</span> </span>
......
<script>
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
export default {
components: {
Icon,
CiIcon,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
return `#${this.job.id}`;
},
},
};
</script>
<template>
<div class="ide-job-item">
<ci-icon
:status="job.status"
:borderless="true"
:size="24"
/>
<span class="prepend-left-8">
{{ job.name }}
<a
:href="job.path"
target="_blank"
class="ide-external-link"
>
{{ jobId }}
<icon
name="external-link"
:size="12"
/>
</a>
</span>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Stage from './stage.vue';
export default {
components: {
LoadingIcon,
Stage,
},
props: {
stages: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
methods: {
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed']),
},
};
</script>
<template>
<div>
<loading-icon
v-if="loading && !stages.length"
class="prepend-top-default"
size="2"
/>
<template v-else>
<stage
v-for="stage in stages"
:key="stage.id"
:stage="stage"
@fetch="fetchJobs"
@toggleCollapsed="toggleStageCollapsed"
/>
</template>
</div>
</template>
<script>
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Item from './item.vue';
export default {
directives: {
tooltip,
},
components: {
Icon,
CiIcon,
LoadingIcon,
Item,
},
props: {
stage: {
type: Object,
required: true,
},
},
data() {
return {
showTooltip: false,
};
},
computed: {
collapseIcon() {
return this.stage.isCollapsed ? 'angle-left' : 'angle-down';
},
showLoadingIcon() {
return this.stage.isLoading && !this.stage.jobs.length;
},
jobsCount() {
return this.stage.jobs.length;
},
},
mounted() {
const { stageTitle } = this.$refs;
this.showTooltip = stageTitle.scrollWidth > stageTitle.offsetWidth;
this.$emit('fetch', this.stage);
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed', this.stage.id);
},
},
};
</script>
<template>
<div
class="ide-stage card prepend-top-default"
>
<div
class="card-header"
:class="{
'border-bottom-0': stage.isCollapsed
}"
@click="toggleCollapsed"
>
<ci-icon
:status="stage.status"
:size="24"
/>
<strong
v-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
class="prepend-left-8 ide-stage-title"
ref="stageTitle"
>
{{ stage.name }}
</strong>
<div
v-if="!stage.isLoading || stage.jobs.length"
class="append-right-8 prepend-left-4"
>
<span class="badge badge-pill">
{{ jobsCount }}
</span>
</div>
<icon
:name="collapseIcon"
css-classes="ide-stage-collapse-icon"
/>
</div>
<div
class="card-body"
v-show="!stage.isCollapsed"
>
<loading-icon
v-if="showLoadingIcon"
/>
<template v-else>
<item
v-for="job in stage.jobs"
:key="job.id"
:job="job"
/>
</template>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import tooltip from '../../../vue_shared/directives/tooltip';
import Icon from '../../../vue_shared/components/icon.vue';
import { rightSidebarViews } from '../../constants';
import PipelinesList from '../pipelines/list.vue';
export default {
directives: {
tooltip,
},
components: {
Icon,
PipelinesList,
},
computed: {
...mapState(['rightPane']),
},
methods: {
...mapActions(['setRightPane']),
clickTab(e, view) {
e.target.blur();
this.setRightPane(view);
},
},
rightSidebarViews,
};
</script>
<template>
<div
class="multi-file-commit-panel ide-right-sidebar"
>
<div
class="multi-file-commit-panel-inner"
v-if="rightPane"
>
<component :is="rightPane" />
</div>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li>
<button
v-tooltip
data-container="body"
data-placement="left"
:title="__('Pipelines')"
class="ide-sidebar-link is-right"
:class="{
active: rightPane === $options.rightSidebarViews.pipelines
}"
type="button"
@click="clickTab($event, $options.rightSidebarViews.pipelines)"
>
<icon
:size="16"
name="pipeline"
/>
</button>
</li>
</ul>
</nav>
</div>
</template>
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import _ from 'underscore';
import { sprintf, __ } from '../../../locale';
import LoadingIcon from '../../../vue_shared/components/loading_icon.vue';
import Icon from '../../../vue_shared/components/icon.vue';
import CiIcon from '../../../vue_shared/components/ci_icon.vue';
import Tabs from '../../../vue_shared/components/tabs/tabs';
import Tab from '../../../vue_shared/components/tabs/tab.vue';
import EmptyState from '../../../pipelines/components/empty_state.vue';
import JobsList from '../jobs/list.vue';
export default {
components: {
LoadingIcon,
Icon,
CiIcon,
Tabs,
Tab,
JobsList,
EmptyState,
},
computed: {
...mapState(['pipelinesEmptyStateSvgPath', 'links']),
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', ['isLoadingPipeline', 'latestPipeline', 'stages', 'isLoadingJobs']),
ciLintText() {
return sprintf(
__('You can also test your .gitlab-ci.yml in the %{linkStart}Lint%{linkEnd}'),
{
linkStart: `<a href="${_.escape(this.currentProject.web_url)}/-/ci/lint">`,
linkEnd: '</a>',
},
false,
);
},
showLoadingIcon() {
return this.isLoadingPipeline && this.latestPipeline === null;
},
},
created() {
this.fetchLatestPipeline();
},
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
},
};
</script>
<template>
<div class="ide-pipeline">
<loading-icon
v-if="showLoadingIcon"
class="prepend-top-default"
size="2"
/>
<template v-else-if="latestPipeline !== null">
<header
v-if="latestPipeline"
class="ide-tree-header ide-pipeline-header"
>
<ci-icon
:status="latestPipeline.details.status"
:size="24"
/>
<span class="prepend-left-8">
<strong>
{{ __('Pipeline') }}
</strong>
<a
:href="latestPipeline.path"
target="_blank"
class="ide-external-link"
>
#{{ latestPipeline.id }}
<icon
name="external-link"
:size="12"
/>
</a>
</span>
</header>
<empty-state
v-if="latestPipeline === false"
:help-page-path="links.ciHelpPagePath"
:empty-state-svg-path="pipelinesEmptyStateSvgPath"
:can-set-ci="true"
/>
<div
v-else-if="latestPipeline.yamlError"
class="bs-callout bs-callout-danger"
>
<p class="append-bottom-0">
{{ __('Found errors in your .gitlab-ci.yml:') }}
</p>
<p class="append-bottom-0">
{{ latestPipeline.yamlError }}
</p>
<p
class="append-bottom-0"
v-html="ciLintText"
></p>
</div>
<tabs
v-else
class="ide-pipeline-list"
>
<tab
:active="!pipelineFailed"
>
<template slot="title">
{{ __('Jobs') }}
<span
v-if="jobsCount"
class="badge badge-pill"
>
{{ jobsCount }}
</span>
</template>
<jobs-list
:loading="isLoadingJobs"
:stages="stages"
/>
</tab>
<tab
:active="pipelineFailed"
>
<template slot="title">
{{ __('Failed Jobs') }}
<span
v-if="failedJobsCount"
class="badge badge-pill"
>
{{ failedJobsCount }}
</span>
</template>
<jobs-list
:loading="isLoadingJobs"
:stages="failedStages"
/>
</tab>
</tabs>
</template>
</div>
</template>
...@@ -20,3 +20,7 @@ export const viewerTypes = { ...@@ -20,3 +20,7 @@ export const viewerTypes = {
edit: 'editor', edit: 'editor',
diff: 'diff', diff: 'diff',
}; };
export const rightSidebarViews = {
pipelines: 'pipelines-list',
};
...@@ -63,7 +63,7 @@ router.beforeEach((to, from, next) => { ...@@ -63,7 +63,7 @@ router.beforeEach((to, from, next) => {
.then(() => { .then(() => {
const fullProjectId = `${to.params.namespace}/${to.params.project}`; const fullProjectId = `${to.params.namespace}/${to.params.project}`;
const baseSplit = to.params[0].split('/-/'); const baseSplit = (to.params[0] && to.params[0].split('/-/')) || [''];
const branchId = baseSplit[0].slice(-1) === '/' ? baseSplit[0].slice(0, -1) : baseSplit[0]; const branchId = baseSplit[0].slice(-1) === '/' ? baseSplit[0].slice(0, -1) : baseSplit[0];
if (branchId) { if (branchId) {
......
import Vue from 'vue'; import Vue from 'vue';
import { mapActions } from 'vuex';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import ide from './components/ide.vue'; import ide from './components/ide.vue';
import store from './stores'; import store from './stores';
...@@ -17,11 +18,18 @@ export function initIde(el) { ...@@ -17,11 +18,18 @@ export function initIde(el) {
ide, ide,
}, },
created() { created() {
this.$store.dispatch('setEmptyStateSvgs', { this.setEmptyStateSvgs({
emptyStateSvgPath: el.dataset.emptyStateSvgPath, emptyStateSvgPath: el.dataset.emptyStateSvgPath,
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath, noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath, committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
}); });
this.setLinks({
ciHelpPagePath: el.dataset.ciHelpPagePath,
});
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks']),
}, },
render(createElement) { render(createElement) {
return createElement('ide'); return createElement('ide');
......
...@@ -169,6 +169,12 @@ export const burstUnusedSeal = ({ state, commit }) => { ...@@ -169,6 +169,12 @@ export const burstUnusedSeal = ({ state, commit }) => {
} }
}; };
export const setRightPane = ({ commit }, view) => {
commit(types.SET_RIGHT_PANE, view);
};
export const setLinks = ({ commit }, links) => commit(types.SET_LINKS, links);
export * from './actions/tree'; export * from './actions/tree';
export * from './actions/file'; export * from './actions/file';
export * from './actions/project'; export * from './actions/project';
......
import Visibility from 'visibilityjs';
import flash from '~/flash'; import flash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import service from '../../services'; import service from '../../services';
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import Poll from '../../../lib/utils/poll';
let eTagPoll;
export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) => export const getProjectData = ({ commit, state }, { namespace, projectId, force = false } = {}) =>
new Promise((resolve, reject) => { new Promise((resolve, reject) => {
...@@ -85,61 +81,3 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {}) ...@@ -85,61 +81,3 @@ export const refreshLastCommitData = ({ commit }, { projectId, branchId } = {})
.catch(() => { .catch(() => {
flash(__('Error loading last commit.'), 'alert', document, null, false, true); flash(__('Error loading last commit.'), 'alert', document, null, false, true);
}); });
export const pollSuccessCallBack = ({ commit, state }, { data }) => {
if (data.pipelines && data.pipelines.length) {
const lastCommitHash =
state.projects[state.currentProjectId].branches[state.currentBranchId].commit.id;
const lastCommitPipeline = data.pipelines.find(
pipeline => pipeline.commit.id === lastCommitHash,
);
commit(types.SET_LAST_COMMIT_PIPELINE, {
projectId: state.currentProjectId,
branchId: state.currentBranchId,
pipeline: lastCommitPipeline || {},
});
}
return data;
};
export const pipelinePoll = ({ getters, dispatch }) => {
eTagPoll = new Poll({
resource: service,
method: 'lastCommitPipelines',
data: {
getters,
},
successCallback: ({ data }) => dispatch('pollSuccessCallBack', { data }),
errorCallback: () => {
flash(
__('Something went wrong while fetching the latest pipeline status.'),
'alert',
document,
null,
false,
true,
);
},
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
};
export const stopPipelinePolling = () => {
eTagPoll.stop();
};
export const restartPipelinePolling = () => {
eTagPoll.restart();
};
...@@ -6,16 +6,21 @@ import * as getters from './getters'; ...@@ -6,16 +6,21 @@ import * as getters from './getters';
import mutations from './mutations'; import mutations from './mutations';
import commitModule from './modules/commit'; import commitModule from './modules/commit';
import pipelines from './modules/pipelines'; import pipelines from './modules/pipelines';
import mergeRequests from './modules/merge_requests';
Vue.use(Vuex); Vue.use(Vuex);
export default new Vuex.Store({ export const createStore = () =>
state: state(), new Vuex.Store({
actions, state: state(),
mutations, actions,
getters, mutations,
modules: { getters,
commit: commitModule, modules: {
pipelines, commit: commitModule,
}, pipelines,
}); mergeRequests,
},
});
export default createStore();
import { __ } from '../../../../locale';
import Api from '../../../../api';
import flash from '../../../../flash';
import * as types from './mutation_types';
export const requestMergeRequests = ({ commit }) => commit(types.REQUEST_MERGE_REQUESTS);
export const receiveMergeRequestsError = ({ commit }) => {
flash(__('Error loading merge requests.'));
commit(types.RECEIVE_MERGE_REQUESTS_ERROR);
};
export const receiveMergeRequestsSuccess = ({ commit }, data) =>
commit(types.RECEIVE_MERGE_REQUESTS_SUCCESS, data);
export const fetchMergeRequests = ({ dispatch, state: { scope, state } }, search = '') => {
dispatch('requestMergeRequests');
dispatch('resetMergeRequests');
Api.mergeRequests({ scope, state, search })
.then(({ data }) => dispatch('receiveMergeRequestsSuccess', data))
.catch(() => dispatch('receiveMergeRequestsError'));
};
export const resetMergeRequests = ({ commit }) => commit(types.RESET_MERGE_REQUESTS);
export default () => {};
export const scopes = {
assignedToMe: 'assigned-to-me',
createdByMe: 'created-by-me',
};
export const states = {
opened: 'opened',
closed: 'closed',
merged: 'merged',
};
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
export default {
namespaced: true,
state: state(),
actions,
mutations,
};
export const REQUEST_MERGE_REQUESTS = 'REQUEST_MERGE_REQUESTS';
export const RECEIVE_MERGE_REQUESTS_ERROR = 'RECEIVE_MERGE_REQUESTS_ERROR';
export const RECEIVE_MERGE_REQUESTS_SUCCESS = 'RECEIVE_MERGE_REQUESTS_SUCCESS';
export const RESET_MERGE_REQUESTS = 'RESET_MERGE_REQUESTS';
/* eslint-disable no-param-reassign */
import * as types from './mutation_types';
export default {
[types.REQUEST_MERGE_REQUESTS](state) {
state.isLoading = true;
},
[types.RECEIVE_MERGE_REQUESTS_ERROR](state) {
state.isLoading = false;
},
[types.RECEIVE_MERGE_REQUESTS_SUCCESS](state, data) {
state.isLoading = false;
state.mergeRequests = data.map(mergeRequest => ({
id: mergeRequest.id,
iid: mergeRequest.iid,
title: mergeRequest.title,
projectId: mergeRequest.project_id,
projectPathWithNamespace: mergeRequest.web_url
.replace(`${gon.gitlab_url}/`, '')
.replace(`/merge_requests/${mergeRequest.iid}`, ''),
}));
},
[types.RESET_MERGE_REQUESTS](state) {
state.mergeRequests = [];
},
};
import { scopes, states } from './constants';
export default () => ({
isLoading: false,
mergeRequests: [],
scope: scopes.assignedToMe,
state: states.opened,
});
import Visibility from 'visibilityjs';
import axios from 'axios';
import { __ } from '../../../../locale'; import { __ } from '../../../../locale';
import Api from '../../../../api';
import flash from '../../../../flash'; import flash from '../../../../flash';
import Poll from '../../../../lib/utils/poll';
import service from '../../../services';
import * as types from './mutation_types'; import * as types from './mutation_types';
let eTagPoll;
export const clearEtagPoll = () => {
eTagPoll = null;
};
export const stopPipelinePolling = () => eTagPoll && eTagPoll.stop();
export const restartPipelinePolling = () => eTagPoll && eTagPoll.restart();
export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE); export const requestLatestPipeline = ({ commit }) => commit(types.REQUEST_LATEST_PIPELINE);
export const receiveLatestPipelineError = ({ commit }) => { export const receiveLatestPipelineError = ({ commit, dispatch }) => {
flash(__('There was an error loading latest pipeline')); flash(__('There was an error loading latest pipeline'));
commit(types.RECEIVE_LASTEST_PIPELINE_ERROR); commit(types.RECEIVE_LASTEST_PIPELINE_ERROR);
dispatch('stopPipelinePolling');
};
export const receiveLatestPipelineSuccess = ({ rootGetters, commit }, { pipelines }) => {
let lastCommitPipeline = false;
if (pipelines && pipelines.length) {
const lastCommitHash = rootGetters.lastCommit && rootGetters.lastCommit.id;
lastCommitPipeline = pipelines.find(pipeline => pipeline.commit.id === lastCommitHash);
}
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, lastCommitPipeline);
}; };
export const receiveLatestPipelineSuccess = ({ commit }, pipeline) =>
commit(types.RECEIVE_LASTEST_PIPELINE_SUCCESS, pipeline);
export const fetchLatestPipeline = ({ dispatch, rootState }, sha) => { export const fetchLatestPipeline = ({ dispatch, rootGetters }) => {
if (eTagPoll) return;
dispatch('requestLatestPipeline'); dispatch('requestLatestPipeline');
return Api.pipelines(rootState.currentProjectId, { sha, per_page: '1' }) eTagPoll = new Poll({
.then(({ data }) => { resource: service,
dispatch('receiveLatestPipelineSuccess', data.pop()); method: 'lastCommitPipelines',
}) data: { getters: rootGetters },
.catch(() => dispatch('receiveLatestPipelineError')); successCallback: ({ data }) => dispatch('receiveLatestPipelineSuccess', data),
errorCallback: () => dispatch('receiveLatestPipelineError'),
});
if (!Visibility.hidden()) {
eTagPoll.makeRequest();
}
Visibility.change(() => {
if (!Visibility.hidden()) {
eTagPoll.restart();
} else {
eTagPoll.stop();
}
});
}; };
export const requestJobs = ({ commit }) => commit(types.REQUEST_JOBS); export const requestJobs = ({ commit }, id) => commit(types.REQUEST_JOBS, id);
export const receiveJobsError = ({ commit }) => { export const receiveJobsError = ({ commit }, id) => {
flash(__('There was an error loading jobs')); flash(__('There was an error loading jobs'));
commit(types.RECEIVE_JOBS_ERROR); commit(types.RECEIVE_JOBS_ERROR, id);
}; };
export const receiveJobsSuccess = ({ commit }, data) => commit(types.RECEIVE_JOBS_SUCCESS, data); export const receiveJobsSuccess = ({ commit }, { id, data }) =>
commit(types.RECEIVE_JOBS_SUCCESS, { id, data });
export const fetchJobs = ({ dispatch, state, rootState }, page = '1') => { export const fetchJobs = ({ dispatch }, stage) => {
dispatch('requestJobs'); dispatch('requestJobs', stage.id);
Api.pipelineJobs(rootState.currentProjectId, state.latestPipeline.id, { axios
page, .get(stage.dropdownPath)
}) .then(({ data }) => dispatch('receiveJobsSuccess', { id: stage.id, data }))
.then(({ data, headers }) => { .catch(() => dispatch('receiveJobsError', stage.id));
const nextPage = headers && headers['x-next-page'];
dispatch('receiveJobsSuccess', data);
if (nextPage) {
dispatch('fetchJobs', nextPage);
}
})
.catch(() => dispatch('receiveJobsError'));
}; };
export const toggleStageCollapsed = ({ commit }, stageId) =>
commit(types.TOGGLE_STAGE_COLLAPSE, stageId);
export default () => {}; export default () => {};
// eslint-disable-next-line import/prefer-default-export
export const states = {
failed: 'failed',
};
import { states } from './constants';
export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline; export const hasLatestPipeline = state => !state.isLoadingPipeline && !!state.latestPipeline;
export const failedJobs = state => export const pipelineFailed = state =>
state.latestPipeline && state.latestPipeline.details.status.text === states.failed;
export const failedStages = state =>
state.stages.filter(stage => stage.status.text.toLowerCase() === states.failed).map(stage => ({
...stage,
jobs: stage.jobs.filter(job => job.status.text.toLowerCase() === states.failed),
}));
export const failedJobsCount = state =>
state.stages.reduce( state.stages.reduce(
(acc, stage) => acc.concat(stage.jobs.filter(job => job.status === 'failed')), (acc, stage) => acc + stage.jobs.filter(j => j.status.text === states.failed).length,
[], 0,
); );
export const jobsCount = state => state.stages.reduce((acc, stage) => acc + stage.jobs.length, 0);
export default () => {};
...@@ -5,3 +5,5 @@ export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCES ...@@ -5,3 +5,5 @@ export const RECEIVE_LASTEST_PIPELINE_SUCCESS = 'RECEIVE_LASTEST_PIPELINE_SUCCES
export const REQUEST_JOBS = 'REQUEST_JOBS'; export const REQUEST_JOBS = 'REQUEST_JOBS';
export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR'; export const RECEIVE_JOBS_ERROR = 'RECEIVE_JOBS_ERROR';
export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS'; export const RECEIVE_JOBS_SUCCESS = 'RECEIVE_JOBS_SUCCESS';
export const TOGGLE_STAGE_COLLAPSE = 'TOGGLE_STAGE_COLLAPSE';
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from './mutation_types'; import * as types from './mutation_types';
import { normalizeJob } from './utils';
export default { export default {
[types.REQUEST_LATEST_PIPELINE](state) { [types.REQUEST_LATEST_PIPELINE](state) {
...@@ -14,40 +15,52 @@ export default { ...@@ -14,40 +15,52 @@ export default {
if (pipeline) { if (pipeline) {
state.latestPipeline = { state.latestPipeline = {
id: pipeline.id, id: pipeline.id,
status: pipeline.status, path: pipeline.path,
commit: pipeline.commit,
details: {
status: pipeline.details.status,
},
yamlError: pipeline.yaml_errors,
}; };
state.stages = pipeline.details.stages.map((stage, i) => {
const foundStage = state.stages.find(s => s.id === i);
return {
id: i,
dropdownPath: stage.dropdown_path,
name: stage.name,
status: stage.status,
isCollapsed: foundStage ? foundStage.isCollapsed : false,
isLoading: foundStage ? foundStage.isLoading : false,
jobs: foundStage ? foundStage.jobs : [],
};
});
} else {
state.latestPipeline = false;
} }
}, },
[types.REQUEST_JOBS](state) { [types.REQUEST_JOBS](state, id) {
state.isLoadingJobs = true; state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? true : stage.isLoading,
}));
}, },
[types.RECEIVE_JOBS_ERROR](state) { [types.RECEIVE_JOBS_ERROR](state, id) {
state.isLoadingJobs = false; state.stages = state.stages.map(stage => ({
...stage,
isLoading: stage.id === id ? false : stage.isLoading,
}));
}, },
[types.RECEIVE_JOBS_SUCCESS](state, jobs) { [types.RECEIVE_JOBS_SUCCESS](state, { id, data }) {
state.isLoadingJobs = false; state.stages = state.stages.map(stage => ({
...stage,
state.stages = jobs.reduce((acc, job) => { isLoading: stage.id === id ? false : stage.isLoading,
let stage = acc.find(s => s.title === job.stage); jobs: stage.id === id ? data.latest_statuses.map(normalizeJob) : stage.jobs,
}));
if (!stage) { },
stage = { [types.TOGGLE_STAGE_COLLAPSE](state, id) {
title: job.stage, state.stages = state.stages.map(stage => ({
jobs: [], ...stage,
}; isCollapsed: stage.id === id ? !stage.isCollapsed : stage.isCollapsed,
}));
acc.push(stage);
}
stage.jobs = stage.jobs.concat({
id: job.id,
name: job.name,
status: job.status,
stage: job.stage,
duration: job.duration,
});
return acc;
}, state.stages);
}, },
}; };
export default () => ({ export default () => ({
isLoadingPipeline: false, isLoadingPipeline: true,
isLoadingJobs: false, isLoadingJobs: false,
latestPipeline: null, latestPipeline: null,
stages: [], stages: [],
......
// eslint-disable-next-line import/prefer-default-export
export const normalizeJob = job => ({
id: job.id,
name: job.name,
status: job.status,
path: job.build_path,
});
...@@ -6,6 +6,7 @@ export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED'; ...@@ -6,6 +6,7 @@ export const SET_LEFT_PANEL_COLLAPSED = 'SET_LEFT_PANEL_COLLAPSED';
export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED'; export const SET_RIGHT_PANEL_COLLAPSED = 'SET_RIGHT_PANEL_COLLAPSED';
export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS'; export const SET_RESIZING_STATUS = 'SET_RESIZING_STATUS';
export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS'; export const SET_EMPTY_STATE_SVGS = 'SET_EMPTY_STATE_SVGS';
export const SET_LINKS = 'SET_LINKS';
// Project Mutation Types // Project Mutation Types
export const SET_PROJECT = 'SET_PROJECT'; export const SET_PROJECT = 'SET_PROJECT';
...@@ -23,7 +24,6 @@ export const SET_BRANCH = 'SET_BRANCH'; ...@@ -23,7 +24,6 @@ export const SET_BRANCH = 'SET_BRANCH';
export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT'; export const SET_BRANCH_COMMIT = 'SET_BRANCH_COMMIT';
export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE'; export const SET_BRANCH_WORKING_REFERENCE = 'SET_BRANCH_WORKING_REFERENCE';
export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN'; export const TOGGLE_BRANCH_OPEN = 'TOGGLE_BRANCH_OPEN';
export const SET_LAST_COMMIT_PIPELINE = 'SET_LAST_COMMIT_PIPELINE';
// Tree mutation types // Tree mutation types
export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA'; export const SET_DIRECTORY_DATA = 'SET_DIRECTORY_DATA';
...@@ -66,3 +66,5 @@ export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW'; ...@@ -66,3 +66,5 @@ export const UPDATE_ACTIVITY_BAR_VIEW = 'UPDATE_ACTIVITY_BAR_VIEW';
export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG'; export const UPDATE_TEMP_FLAG = 'UPDATE_TEMP_FLAG';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER'; export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL'; export const BURST_UNUSED_SEAL = 'BURST_UNUSED_SEAL';
export const SET_RIGHT_PANE = 'SET_RIGHT_PANE';
...@@ -114,12 +114,13 @@ export default { ...@@ -114,12 +114,13 @@ export default {
}, },
[types.SET_EMPTY_STATE_SVGS]( [types.SET_EMPTY_STATE_SVGS](
state, state,
{ emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath }, { emptyStateSvgPath, noChangesStateSvgPath, committedStateSvgPath, pipelinesEmptyStateSvgPath },
) { ) {
Object.assign(state, { Object.assign(state, {
emptyStateSvgPath, emptyStateSvgPath,
noChangesStateSvgPath, noChangesStateSvgPath,
committedStateSvgPath, committedStateSvgPath,
pipelinesEmptyStateSvgPath,
}); });
}, },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) { [types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
...@@ -148,6 +149,14 @@ export default { ...@@ -148,6 +149,14 @@ export default {
unusedSeal: false, unusedSeal: false,
}); });
}, },
[types.SET_RIGHT_PANE](state, view) {
Object.assign(state, {
rightPane: state.rightPane === view ? null : view,
});
},
[types.SET_LINKS](state, links) {
Object.assign(state, { links });
},
...projectMutations, ...projectMutations,
...mergeRequestMutation, ...mergeRequestMutation,
...fileMutations, ...fileMutations,
......
...@@ -14,10 +14,6 @@ export default { ...@@ -14,10 +14,6 @@ export default {
treeId: `${projectPath}/${branchName}`, treeId: `${projectPath}/${branchName}`,
active: true, active: true,
workingReference: '', workingReference: '',
commit: {
...branch.commit,
pipeline: {},
},
}, },
}, },
}); });
...@@ -32,9 +28,4 @@ export default { ...@@ -32,9 +28,4 @@ export default {
commit, commit,
}); });
}, },
[types.SET_LAST_COMMIT_PIPELINE](state, { projectId, branchId, pipeline }) {
Object.assign(state.projects[projectId].branches[branchId].commit, {
pipeline,
});
},
}; };
...@@ -23,4 +23,6 @@ export default () => ({ ...@@ -23,4 +23,6 @@ export default () => ({
currentActivityView: activityBarViews.edit, currentActivityView: activityBarViews.edit,
unusedSeal: true, unusedSeal: true,
fileFindVisible: false, fileFindVisible: false,
rightPane: null,
links: {},
}); });
...@@ -101,13 +101,19 @@ export default class IntegrationSettingsForm { ...@@ -101,13 +101,19 @@ export default class IntegrationSettingsForm {
return axios.put(this.testEndPoint, formData) return axios.put(this.testEndPoint, formData)
.then(({ data }) => { .then(({ data }) => {
if (data.error) { if (data.error) {
flash(`${data.message} ${data.service_response}`, 'alert', document, { let flashActions;
title: 'Save anyway',
clickHandler: (e) => { if (data.test_failed) {
e.preventDefault(); flashActions = {
this.$form.submit(); title: 'Save anyway',
}, clickHandler: (e) => {
}); e.preventDefault();
this.$form.submit();
},
};
}
flash(`${data.message} ${data.service_response}`, 'alert', document, flashActions);
} else { } else {
this.$form.submit(); this.$form.submit();
} }
......
/* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */ /* eslint-disable comma-dangle, class-methods-use-this, no-underscore-dangle, no-param-reassign, no-unused-vars, consistent-return, func-names, space-before-function-paren, max-len */
import $ from 'jquery'; import $ from 'jquery';
import Sortable from 'vendor/Sortable'; import Sortable from 'sortablejs';
import flash from './flash'; import flash from './flash';
import axios from './lib/utils/axios_utils'; import axios from './lib/utils/axios_utils';
......
...@@ -79,12 +79,13 @@ export default { ...@@ -79,12 +79,13 @@ export default {
}; };
</script> </script>
<template> <template>
<div class="ci-job-dropdown-container dropdown"> <div class="ci-job-dropdown-container dropdown dropright">
<button <button
v-tooltip v-tooltip
type="button" type="button"
data-toggle="dropdown" data-toggle="dropdown"
data-container="body" data-container="body"
data-boundary="viewport"
class="dropdown-menu-toggle build-content" class="dropdown-menu-toggle build-content"
:title="tooltipText" :title="tooltipText"
> >
......
...@@ -22,6 +22,8 @@ import Icon from '../../vue_shared/components/icon.vue'; ...@@ -22,6 +22,8 @@ import Icon from '../../vue_shared/components/icon.vue';
* - Jobs show view header * - Jobs show view header
* - Jobs show view sidebar * - Jobs show view sidebar
*/ */
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
export default { export default {
components: { components: {
Icon, Icon,
...@@ -31,17 +33,36 @@ export default { ...@@ -31,17 +33,36 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
size: {
type: Number,
required: false,
default: 16,
validator(value) {
return validSizes.includes(value);
},
},
borderless: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { computed: {
cssClass() { cssClass() {
const status = this.status.group; const status = this.status.group;
return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`; return `ci-status-icon ci-status-icon-${status} js-ci-status-icon-${status}`;
}, },
icon() {
return this.borderless ? `${this.status.icon}_borderless` : this.status.icon;
},
}, },
}; };
</script> </script>
<template> <template>
<span :class="cssClass"> <span :class="cssClass">
<icon :name="status.icon" /> <icon
:name="icon"
:size="size"
/>
</span> </span>
</template> </template>
<script>
export default {
props: {
title: {
type: String,
required: false,
default: '',
},
active: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
// props can't be updated, so we map it to data where we can
localActive: this.active,
};
},
watch: {
active() {
this.localActive = this.active;
},
},
created() {
this.isTab = true;
},
};
</script>
<template>
<div
class="tab-pane"
:class="{
active: localActive
}"
role="tabpanel"
>
<slot></slot>
</div>
</template>
export default {
data() {
return {
currentIndex: 0,
tabs: [],
};
},
mounted() {
this.updateTabs();
},
methods: {
updateTabs() {
this.tabs = this.$children.filter(child => child.isTab);
this.currentIndex = this.tabs.findIndex(tab => tab.localActive);
},
setTab(index) {
this.tabs[this.currentIndex].localActive = false;
this.tabs[index].localActive = true;
this.currentIndex = index;
},
},
render(h) {
const navItems = this.tabs.map((tab, i) =>
h(
'li',
{
key: i,
},
[
h(
'a',
{
class: tab.localActive ? 'active' : null,
attrs: {
href: '#',
},
on: {
click: () => this.setTab(i),
},
},
tab.$slots.title || tab.title,
),
],
),
);
const nav = h(
'ul',
{
class: 'nav-links tab-links',
},
[navItems],
);
const content = h(
'div',
{
class: ['tab-content'],
},
[this.$slots.default],
);
return h('div', {}, [[nav], content]);
},
};
...@@ -2,7 +2,9 @@ import $ from 'jquery'; ...@@ -2,7 +2,9 @@ import $ from 'jquery';
export default { export default {
bind(el) { bind(el) {
$(el).tooltip(); $(el).tooltip({
trigger: 'hover',
});
}, },
componentUpdated(el) { componentUpdated(el) {
......
...@@ -36,6 +36,17 @@ html [type="button"], ...@@ -36,6 +36,17 @@ html [type="button"],
cursor: pointer; cursor: pointer;
} }
input[type="file"] {
// Bootstrap 4 file input height is taller by default
// which makes them look ugly
line-height: 1;
}
b,
strong {
font-weight: bold;
}
a { a {
color: $gl-link-color; color: $gl-link-color;
} }
...@@ -48,6 +59,12 @@ a { ...@@ -48,6 +59,12 @@ a {
} }
} }
code {
padding: 2px 4px;
background-color: $red-100;
border-radius: 3px;
}
table { table {
// Remove any table border lines // Remove any table border lines
border-spacing: 0; border-spacing: 0;
...@@ -87,7 +104,8 @@ table { ...@@ -87,7 +104,8 @@ table {
display: none; display: none;
} }
.dropdown-toggle::after { .dropdown-toggle::after,
.dropright .dropdown-menu-toggle::after {
// Remove bootstrap's dropdown caret // Remove bootstrap's dropdown caret
display: none; display: none;
} }
......
/*
* This is a minimal stylesheet, meant to be used for error pages.
*/
@import 'framework/variables';
@import '../../../node_modules/bootstrap/scss/functions';
@import '../../../node_modules/bootstrap/scss/variables';
@import '../../../node_modules/bootstrap/scss/mixins';
@import '../../../node_modules/bootstrap/scss/reboot';
@import '../../../node_modules/bootstrap/scss/buttons';
@import '../../../node_modules/bootstrap/scss/forms';
$body-color: #666;
$header-color: #456;
body {
color: $body-color;
text-align: center;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
margin: auto;
font-size: 14px;
}
h1 {
font-size: 56px;
line-height: 100px;
font-weight: 400;
color: $header-color;
}
h2 {
font-size: 24px;
color: $body-color;
line-height: 1.5em;
}
h3 {
color: $header-color;
font-size: 20px;
font-weight: 400;
line-height: 28px;
}
img {
max-width: 80vw;
display: block;
margin: 40px auto;
}
a {
text-decoration: none;
color: $blue-600;
}
.page-container {
margin: auto 20px;
}
.container {
margin: auto;
max-width: 600px;
border-bottom: 1px solid $border-color;
padding-bottom: 1em;
}
.action-container {
padding: 0.5em 0;
}
.form-inline-flex {
display: flex;
flex-wrap: wrap;
button {
display: block;
width: 100%;
}
.field {
display: block;
width: 100%;
margin-bottom: 1em;
}
@include media-breakpoint-up(sm) {
flex-wrap: nowrap;
button {
width: auto;
}
.field {
margin-bottom: 0;
margin-right: 0.5em;
}
}
}
.error-nav {
padding: 0;
text-align: center;
li {
display: block;
padding-bottom: 1em;
}
@include media-breakpoint-up(sm) {
li {
display: inline-block;
padding-bottom: 0;
&:not(:first-child)::before {
content: '\00B7';
display: inline-block;
padding: 0 1em;
}
}
}
}
...@@ -192,6 +192,10 @@ ...@@ -192,6 +192,10 @@
&.active { &.active {
color: $color-700; color: $color-700;
box-shadow: inset 3px 0 $color-700; box-shadow: inset 3px 0 $color-700;
&.is-right {
box-shadow: inset -3px 0 $color-700;
}
} }
} }
} }
......
...@@ -19,14 +19,23 @@ ...@@ -19,14 +19,23 @@
width: auto; width: auto;
display: inline-block; display: inline-block;
overflow-x: auto; overflow-x: auto;
border-left: 0; border: 0;
border-right: 0; border-color: $md-area-border;
border-bottom: 0;
@supports(width: fit-content) { @supports(width: fit-content) {
display: block; display: block;
width: fit-content; width: fit-content;
} }
tr {
th {
border-bottom: solid 2px $md-area-border;
}
td {
border-color: $md-area-border;
}
}
} }
/* /*
......
.modal-header { .modal-header {
background-color: $modal-body-bg; background-color: $modal-body-bg;
padding: #{3 * $grid-size} #{2 * $grid-size};
.page-title, .page-title,
.modal-title { .modal-title {
......
...@@ -340,10 +340,6 @@ code { ...@@ -340,10 +340,6 @@ code {
} }
} }
a > code {
color: $link-color;
}
.monospace { .monospace {
font-family: $monospace_font; font-family: $monospace_font;
} }
......
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
.cluster-applications-table { .cluster-applications-table {
// Wait for the Vue to kick-in and render the applications block // Wait for the Vue to kick-in and render the applications block
min-height: 400px; min-height: 628px;
} }
.clusters-dropdown-menu { .clusters-dropdown-menu {
......
...@@ -909,6 +909,16 @@ ...@@ -909,6 +909,16 @@
width: 1px; width: 1px;
background: $white-light; background: $white-light;
} }
&.is-right {
padding-right: $gl-padding;
padding-left: $gl-padding + 1px;
&::after {
right: auto;
left: -1px;
}
}
} }
} }
...@@ -1121,3 +1131,112 @@ ...@@ -1121,3 +1131,112 @@
white-space: nowrap; white-space: nowrap;
} }
} }
.ide-external-link {
svg {
display: none;
}
&:hover,
&:focus {
svg {
display: inline-block;
}
}
}
.ide-right-sidebar {
width: auto;
min-width: 60px;
.ide-activity-bar {
border-left: 1px solid $white-dark;
}
.multi-file-commit-panel-inner {
width: 350px;
padding: $grid-size $gl-padding;
background-color: $white-light;
border-left: 1px solid $white-dark;
}
}
.ide-pipeline {
display: flex;
flex-direction: column;
height: 100%;
.empty-state {
margin-top: auto;
margin-bottom: auto;
p {
margin: $grid-size 0;
text-align: center;
line-height: 24px;
}
.btn,
h4 {
margin: 0;
}
}
}
.ide-pipeline-list {
flex: 1;
overflow: auto;
}
.ide-pipeline-header {
min-height: 50px;
padding-left: $gl-padding;
padding-right: $gl-padding;
.ci-status-icon {
display: flex;
}
}
.ide-job-item {
display: flex;
padding: 16px;
&:not(:last-child) {
border-bottom: 1px solid $border-color;
}
.ci-status-icon {
display: flex;
justify-content: center;
height: 20px;
margin-top: -2px;
overflow: hidden;
}
}
.ide-stage {
.card-header {
display: flex;
cursor: pointer;
.ci-status-icon {
display: flex;
align-items: center;
}
}
.card-body {
padding: 0;
}
}
.ide-stage-collapse-icon {
margin: auto 0 auto auto;
}
.ide-stage-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
...@@ -4,9 +4,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController ...@@ -4,9 +4,7 @@ class Admin::RunnerProjectsController < Admin::ApplicationController
def create def create
@runner = Ci::Runner.find(params[:runner_project][:runner_id]) @runner = Ci::Runner.find(params[:runner_project][:runner_id])
runner_project = @runner.assign_to(@project, current_user) if @runner.assign_to(@project, current_user)
if runner_project.persisted?
redirect_to admin_runner_path(@runner) redirect_to admin_runner_path(@runner)
else else
redirect_to admin_runner_path(@runner), alert: 'Failed adding runner to project' redirect_to admin_runner_path(@runner), alert: 'Failed adding runner to project'
......
...@@ -146,14 +146,15 @@ class ApplicationController < ActionController::Base ...@@ -146,14 +146,15 @@ class ApplicationController < ActionController::Base
end end
def render_403 def render_403
head :forbidden respond_to do |format|
format.any { head :forbidden }
format.html { render "errors/access_denied", layout: "errors", status: 403 }
end
end end
def render_404 def render_404
respond_to do |format| respond_to do |format|
format.html do format.html { render "errors/not_found", layout: "errors", status: 404 }
render file: Rails.root.join("public", "404"), layout: false, status: "404"
end
# Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file # Prevent the Rails CSRF protector from thinking a missing .js file is a JavaScript file
format.js { render json: '', status: :not_found, content_type: 'application/json' } format.js { render json: '', status: :not_found, content_type: 'application/json' }
format.any { head :not_found } format.any { head :not_found }
......
...@@ -5,7 +5,17 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll ...@@ -5,7 +5,17 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll
before_action :authorize_create_cluster!, only: [:create] before_action :authorize_create_cluster!, only: [:create]
def create def create
application = @application_class.find_or_create_by!(cluster: @cluster) application = @application_class.find_or_initialize_by(cluster: @cluster)
if application.has_attribute?(:hostname)
application.hostname = params[:hostname]
end
if application.respond_to?(:oauth_application)
application.oauth_application = create_oauth_application(application)
end
application.save!
Clusters::Applications::ScheduleInstallationService.new(project, current_user).execute(application) Clusters::Applications::ScheduleInstallationService.new(project, current_user).execute(application)
...@@ -23,4 +33,15 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll ...@@ -23,4 +33,15 @@ class Projects::Clusters::ApplicationsController < Projects::ApplicationControll
def application_class def application_class
@application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404 @application_class ||= Clusters::Cluster::APPLICATIONS[params[:application]] || render_404
end end
def create_oauth_application(application)
oauth_application_params = {
name: params[:application],
redirect_uri: application.callback_url,
scopes: 'api read_user openid',
owner: current_user
}
Applications::CreateService.new(current_user, oauth_application_params).execute
end
end end
...@@ -9,9 +9,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController ...@@ -9,9 +9,8 @@ class Projects::RunnerProjectsController < Projects::ApplicationController
return head(403) unless can?(current_user, :assign_runner, @runner) return head(403) unless can?(current_user, :assign_runner, @runner)
path = project_runners_path(project) path = project_runners_path(project)
runner_project = @runner.assign_to(project, current_user)
if runner_project.persisted? if @runner.assign_to(project, current_user)
redirect_to path redirect_to path
else else
redirect_to path, alert: 'Failed adding runner to project' redirect_to path, alert: 'Failed adding runner to project'
......
...@@ -41,13 +41,13 @@ class Projects::ServicesController < Projects::ApplicationController ...@@ -41,13 +41,13 @@ class Projects::ServicesController < Projects::ApplicationController
if outcome[:success] if outcome[:success]
{} {}
else else
{ error: true, message: 'Test failed.', service_response: outcome[:result].to_s } { error: true, message: 'Test failed.', service_response: outcome[:result].to_s, test_failed: true }
end end
else else
{ error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(',') } { error: true, message: 'Validations failed.', service_response: @service.errors.full_messages.join(','), test_failed: false }
end end
rescue Gitlab::HTTP::BlockedUrlError => e rescue Gitlab::HTTP::BlockedUrlError => e
{ error: true, message: 'Test failed.', service_response: e.message } { error: true, message: 'Test failed.', service_response: e.message, test_failed: true }
end end
def success_message def success_message
......
require 'gitlab/webpack/manifest'
module WebpackHelper module WebpackHelper
def webpack_bundle_tag(bundle) def webpack_bundle_tag(bundle)
javascript_include_tag(*webpack_entrypoint_paths(bundle)) javascript_include_tag(*webpack_entrypoint_paths(bundle))
......
...@@ -18,7 +18,7 @@ class Badge < ActiveRecord::Base ...@@ -18,7 +18,7 @@ class Badge < ActiveRecord::Base
scope :order_created_at_asc, -> { reorder(created_at: :asc) } scope :order_created_at_asc, -> { reorder(created_at: :asc) }
validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX } validates :link_url, :image_url, url: { protocols: %w(http https) }
validates :type, presence: true validates :type, presence: true
def rendered_link_url(project = nil) def rendered_link_url(project = nil)
......
...@@ -12,9 +12,9 @@ module Ci ...@@ -12,9 +12,9 @@ module Ci
FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze
has_many :builds has_many :builds
has_many :runner_projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :projects, through: :runner_projects has_many :projects, through: :runner_projects
has_many :runner_namespaces has_many :runner_namespaces, inverse_of: :runner
has_many :groups, through: :runner_namespaces has_many :groups, through: :runner_namespaces
has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build' has_one :last_build, ->() { order('id DESC') }, class_name: 'Ci::Build'
...@@ -56,10 +56,15 @@ module Ci ...@@ -56,10 +56,15 @@ module Ci
end end
validate :tag_constraints validate :tag_constraints
validate :either_projects_or_group
validates :access_level, presence: true validates :access_level, presence: true
validates :runner_type, presence: true validates :runner_type, presence: true
validate :no_projects, unless: :project_type?
validate :no_groups, unless: :group_type?
validate :any_project, if: :project_type?
validate :exactly_one_group, if: :group_type?
validate :validate_is_shared
acts_as_taggable acts_as_taggable
after_destroy :cleanup_runner_queue after_destroy :cleanup_runner_queue
...@@ -115,8 +120,15 @@ module Ci ...@@ -115,8 +120,15 @@ module Ci
raise ArgumentError, 'Transitioning a group runner to a project runner is not supported' raise ArgumentError, 'Transitioning a group runner to a project runner is not supported'
end end
self.save begin
project.runner_projects.create(runner_id: self.id) transaction do
self.projects << project
self.save!
end
rescue ActiveRecord::RecordInvalid => e
self.errors.add(:assign_to, e.message)
false
end
end end
def display_name def display_name
...@@ -253,13 +265,33 @@ module Ci ...@@ -253,13 +265,33 @@ module Ci
self.class.owned_or_shared(project_id).where(id: self.id).any? self.class.owned_or_shared(project_id).where(id: self.id).any?
end end
def either_projects_or_group def no_projects
if groups.many? if projects.any?
errors.add(:runner, 'can only be assigned to one group') errors.add(:runner, 'cannot have projects assigned')
end
end
def no_groups
if groups.any?
errors.add(:runner, 'cannot have groups assigned')
end
end
def any_project
unless projects.any?
errors.add(:runner, 'needs to be assigned to at least one project')
end
end
def exactly_one_group
unless groups.one?
errors.add(:runner, 'needs to be assigned to exactly one group')
end end
end
if assigned_to_group? && assigned_to_project? def validate_is_shared
errors.add(:runner, 'can only be assigned either to projects or to a group') unless is_shared? == instance_type?
errors.add(:is_shared, 'is not equal to instance_type?')
end end
end end
......
...@@ -2,8 +2,10 @@ module Ci ...@@ -2,8 +2,10 @@ module Ci
class RunnerNamespace < ActiveRecord::Base class RunnerNamespace < ActiveRecord::Base
extend Gitlab::Ci::Model extend Gitlab::Ci::Model
belongs_to :runner belongs_to :runner, inverse_of: :runner_namespaces, validate: true
belongs_to :namespace, class_name: '::Namespace' belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace'
belongs_to :group, class_name: '::Group', foreign_key: :namespace_id belongs_to :group, class_name: '::Group', foreign_key: :namespace_id
validates :runner_id, uniqueness: { scope: :namespace_id }
end end
end end
...@@ -2,8 +2,8 @@ module Ci ...@@ -2,8 +2,8 @@ module Ci
class RunnerProject < ActiveRecord::Base class RunnerProject < ActiveRecord::Base
extend Gitlab::Ci::Model extend Gitlab::Ci::Model
belongs_to :runner belongs_to :runner, inverse_of: :runner_projects
belongs_to :project belongs_to :project, inverse_of: :runner_projects
validates :runner_id, uniqueness: { scope: :project_id } validates :runner_id, uniqueness: { scope: :project_id }
end end
......
module Clusters
module Applications
class Jupyter < ActiveRecord::Base
VERSION = '0.0.1'.freeze
self.table_name = 'clusters_applications_jupyter'
include ::Clusters::Concerns::ApplicationCore
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
belongs_to :oauth_application, class_name: 'Doorkeeper::Application'
default_value_for :version, VERSION
def set_initial_status
return unless not_installable?
if cluster&.application_ingress_installed? && cluster.application_ingress.external_ip
self.status = 'installable'
end
end
def chart
"#{name}/jupyterhub"
end
def repository
'https://jupyterhub.github.io/helm-chart/'
end
def values
content_values.to_yaml
end
def install_command
Gitlab::Kubernetes::Helm::InstallCommand.new(
name,
chart: chart,
values: values,
repository: repository
)
end
def callback_url
"http://#{hostname}/hub/oauth_callback"
end
private
def specification
{
"ingress" => {
"hosts" => [hostname]
},
"hub" => {
"extraEnv" => {
"GITLAB_HOST" => gitlab_url
},
"cookieSecret" => cookie_secret
},
"proxy" => {
"secretToken" => secret_token
},
"auth" => {
"gitlab" => {
"clientId" => oauth_application.uid,
"clientSecret" => oauth_application.secret,
"callbackUrl" => callback_url
}
}
}
end
def gitlab_url
Gitlab.config.gitlab.url
end
def content_values
YAML.load_file(chart_values_file).deep_merge!(specification)
end
def secret_token
@secret_token ||= SecureRandom.hex(32)
end
def cookie_secret
@cookie_secret ||= SecureRandom.hex(32)
end
end
end
end
...@@ -43,7 +43,7 @@ module Clusters ...@@ -43,7 +43,7 @@ module Clusters
def create_and_assign_runner def create_and_assign_runner
transaction do transaction do
project.runners.create!(runner_create_params).tap do |runner| Ci::Runner.create!(runner_create_params).tap do |runner|
update!(runner_id: runner.id) update!(runner_id: runner.id)
end end
end end
...@@ -53,7 +53,8 @@ module Clusters ...@@ -53,7 +53,8 @@ module Clusters
{ {
name: 'kubernetes-cluster', name: 'kubernetes-cluster',
runner_type: :project_type, runner_type: :project_type,
tag_list: %w(kubernetes cluster) tag_list: %w(kubernetes cluster),
projects: [project]
} }
end end
......
...@@ -8,7 +8,8 @@ module Clusters ...@@ -8,7 +8,8 @@ module Clusters
Applications::Helm.application_name => Applications::Helm, Applications::Helm.application_name => Applications::Helm,
Applications::Ingress.application_name => Applications::Ingress, Applications::Ingress.application_name => Applications::Ingress,
Applications::Prometheus.application_name => Applications::Prometheus, Applications::Prometheus.application_name => Applications::Prometheus,
Applications::Runner.application_name => Applications::Runner Applications::Runner.application_name => Applications::Runner,
Applications::Jupyter.application_name => Applications::Jupyter
}.freeze }.freeze
DEFAULT_ENVIRONMENT = '*'.freeze DEFAULT_ENVIRONMENT = '*'.freeze
...@@ -26,6 +27,7 @@ module Clusters ...@@ -26,6 +27,7 @@ module Clusters
has_one :application_ingress, class_name: 'Clusters::Applications::Ingress' has_one :application_ingress, class_name: 'Clusters::Applications::Ingress'
has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus' has_one :application_prometheus, class_name: 'Clusters::Applications::Prometheus'
has_one :application_runner, class_name: 'Clusters::Applications::Runner' has_one :application_runner, class_name: 'Clusters::Applications::Runner'
has_one :application_jupyter, class_name: 'Clusters::Applications::Jupyter'
accepts_nested_attributes_for :provider_gcp, update_only: true accepts_nested_attributes_for :provider_gcp, update_only: true
accepts_nested_attributes_for :platform_kubernetes, update_only: true accepts_nested_attributes_for :platform_kubernetes, update_only: true
...@@ -39,6 +41,7 @@ module Clusters ...@@ -39,6 +41,7 @@ module Clusters
delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true delegate :active?, to: :platform_kubernetes, prefix: true, allow_nil: true
delegate :installed?, to: :application_helm, prefix: true, allow_nil: true delegate :installed?, to: :application_helm, prefix: true, allow_nil: true
delegate :installed?, to: :application_ingress, prefix: true, allow_nil: true
enum platform_type: { enum platform_type: {
kubernetes: 1 kubernetes: 1
...@@ -74,7 +77,8 @@ module Clusters ...@@ -74,7 +77,8 @@ module Clusters
application_helm || build_application_helm, application_helm || build_application_helm,
application_ingress || build_application_ingress, application_ingress || build_application_ingress,
application_prometheus || build_application_prometheus, application_prometheus || build_application_prometheus,
application_runner || build_application_runner application_runner || build_application_runner,
application_jupyter || build_application_jupyter
] ]
end end
......
...@@ -11,12 +11,12 @@ module Clusters ...@@ -11,12 +11,12 @@ module Clusters
attr_encrypted :password, attr_encrypted :password,
mode: :per_attribute_iv, mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
attr_encrypted :token, attr_encrypted :token,
mode: :per_attribute_iv, mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
before_validation :enforce_namespace_to_lower_case before_validation :enforce_namespace_to_lower_case
......
...@@ -11,7 +11,7 @@ module Clusters ...@@ -11,7 +11,7 @@ module Clusters
attr_encrypted :access_token, attr_encrypted :access_token,
mode: :per_attribute_iv, mode: :per_attribute_iv,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
validates :gcp_project_id, validates :gcp_project_id,
......
...@@ -6,15 +6,16 @@ module CacheableAttributes ...@@ -6,15 +6,16 @@ module CacheableAttributes
end end
class_methods do class_methods do
def cache_key
"#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:#{Rails.version}".freeze
end
# Can be overriden # Can be overriden
def current_without_cache def current_without_cache
last last
end end
def cache_key # Can be overriden
"#{name}:#{Gitlab::VERSION}:#{Gitlab.migrations_hash}:json".freeze
end
def defaults def defaults
{} {}
end end
...@@ -24,10 +25,18 @@ module CacheableAttributes ...@@ -24,10 +25,18 @@ module CacheableAttributes
end end
def cached def cached
json_attributes = Rails.cache.read(cache_key) if RequestStore.active?
return nil unless json_attributes.present? RequestStore[:"#{name}_cached_attributes"] ||= retrieve_from_cache
else
retrieve_from_cache
end
end
def retrieve_from_cache
record = Rails.cache.read(cache_key)
ensure_cache_setup if record.present?
build_from_defaults(JSON.parse(json_attributes)) record
end end
def current def current
...@@ -35,7 +44,12 @@ module CacheableAttributes ...@@ -35,7 +44,12 @@ module CacheableAttributes
return cached_record if cached_record.present? return cached_record if cached_record.present?
current_without_cache.tap { |current_record| current_record&.cache! } current_without_cache.tap { |current_record| current_record&.cache! }
rescue rescue => e
if Rails.env.production?
Rails.logger.warn("Cached record for #{name} couldn't be loaded, falling back to uncached record: #{e}")
else
raise e
end
# Fall back to an uncached value if there are any problems (e.g. Redis down) # Fall back to an uncached value if there are any problems (e.g. Redis down)
current_without_cache current_without_cache
end end
...@@ -46,9 +60,15 @@ module CacheableAttributes ...@@ -46,9 +60,15 @@ module CacheableAttributes
# Gracefully handle when Redis is not available. For example, # Gracefully handle when Redis is not available. For example,
# omnibus may fail here during gitlab:assets:compile. # omnibus may fail here during gitlab:assets:compile.
end end
def ensure_cache_setup
# This is a workaround for a Rails bug that causes attribute methods not
# to be loaded when read from cache: https://github.com/rails/rails/issues/27348
define_attribute_methods
end
end end
def cache! def cache!
Rails.cache.write(self.class.cache_key, attributes.to_json) Rails.cache.write(self.class.cache_key, self)
end end
end end
...@@ -13,7 +13,7 @@ module HasVariable ...@@ -13,7 +13,7 @@ module HasVariable
attr_encrypted :value, attr_encrypted :value,
mode: :per_attribute_iv_and_salt, mode: :per_attribute_iv_and_salt,
insecure_mode: true, insecure_mode: true,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
def key=(new_key) def key=(new_key)
......
...@@ -74,6 +74,7 @@ module ReactiveCaching ...@@ -74,6 +74,7 @@ module ReactiveCaching
def clear_reactive_cache!(*args) def clear_reactive_cache!(*args)
Rails.cache.delete(full_reactive_cache_key(*args)) Rails.cache.delete(full_reactive_cache_key(*args))
Rails.cache.delete(alive_reactive_cache_key(*args))
end end
def exclusively_update_reactive_cache!(*args) def exclusively_update_reactive_cache!(*args)
......
...@@ -30,8 +30,6 @@ module TimeTrackable ...@@ -30,8 +30,6 @@ module TimeTrackable
return if @time_spent == 0 return if @time_spent == 0
touch if touchable?
if @time_spent == :reset if @time_spent == :reset
reset_spent_time reset_spent_time
else else
...@@ -59,10 +57,6 @@ module TimeTrackable ...@@ -59,10 +57,6 @@ module TimeTrackable
private private
def touchable?
valid? && persisted?
end
def reset_spent_time def reset_spent_time
timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
......
...@@ -32,7 +32,7 @@ class Environment < ActiveRecord::Base ...@@ -32,7 +32,7 @@ class Environment < ActiveRecord::Base
validates :external_url, validates :external_url,
length: { maximum: 255 }, length: { maximum: 255 },
allow_nil: true, allow_nil: true,
addressable_url: true url: true
delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true delegate :stop_action, :manual_actions, to: :last_deployment, allow_nil: true
......
class GenericCommitStatus < CommitStatus class GenericCommitStatus < CommitStatus
before_validation :set_default_values before_validation :set_default_values
validates :target_url, addressable_url: true, validates :target_url, url: true,
length: { maximum: 255 }, length: { maximum: 255 },
allow_nil: true allow_nil: true
......
...@@ -11,4 +11,9 @@ class SystemHook < WebHook ...@@ -11,4 +11,9 @@ class SystemHook < WebHook
default_value_for :push_events, false default_value_for :push_events, false
default_value_for :repository_update_events, true default_value_for :repository_update_events, true
default_value_for :merge_requests_events, false default_value_for :merge_requests_events, false
# Allow urls pointing localhost and the local network
def allow_local_requests?
true
end
end end
...@@ -3,7 +3,9 @@ class WebHook < ActiveRecord::Base ...@@ -3,7 +3,9 @@ class WebHook < ActiveRecord::Base
has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :web_hook_logs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
validates :url, presence: true, url: true validates :url, presence: true, public_url: { allow_localhost: lambda(&:allow_local_requests?),
allow_local_network: lambda(&:allow_local_requests?) }
validates :token, format: { without: /\n/ } validates :token, format: { without: /\n/ }
def execute(data, hook_name) def execute(data, hook_name)
...@@ -13,4 +15,9 @@ class WebHook < ActiveRecord::Base ...@@ -13,4 +15,9 @@ class WebHook < ActiveRecord::Base
def async_execute(data, hook_name) def async_execute(data, hook_name)
WebHookService.new(self, data, hook_name).async_execute WebHookService.new(self, data, hook_name).async_execute
end end
# Allow urls pointing localhost and the local network
def allow_local_requests?
false
end
end end
...@@ -21,7 +21,7 @@ class Namespace < ActiveRecord::Base ...@@ -21,7 +21,7 @@ class Namespace < ActiveRecord::Base
has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :project_statistics has_many :project_statistics
has_many :runner_namespaces, class_name: 'Ci::RunnerNamespace' has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace'
has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner'
# This should _not_ be `inverse_of: :namespace`, because that would also set # This should _not_ be `inverse_of: :namespace`, because that would also set
......
...@@ -19,7 +19,7 @@ class PagesDomain < ActiveRecord::Base ...@@ -19,7 +19,7 @@ class PagesDomain < ActiveRecord::Base
attr_encrypted :key, attr_encrypted :key,
mode: :per_attribute_iv_and_salt, mode: :per_attribute_iv_and_salt,
insecure_mode: true, insecure_mode: true,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
algorithm: 'aes-256-cbc' algorithm: 'aes-256-cbc'
after_initialize :set_verification_code after_initialize :set_verification_code
......
...@@ -236,7 +236,7 @@ class Project < ActiveRecord::Base ...@@ -236,7 +236,7 @@ class Project < ActiveRecord::Base
has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName'
has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
has_many :runner_projects, class_name: 'Ci::RunnerProject' has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable' has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, class_name: 'Ci::Trigger' has_many :triggers, class_name: 'Ci::Trigger'
...@@ -289,8 +289,9 @@ class Project < ActiveRecord::Base ...@@ -289,8 +289,9 @@ class Project < ActiveRecord::Base
validates :namespace, presence: true validates :namespace, presence: true
validates :name, uniqueness: { scope: :namespace_id } validates :name, uniqueness: { scope: :namespace_id }
validates :import_url, addressable_url: true, if: :external_import? validates :import_url, url: { protocols: %w(http https ssh git),
validates :import_url, importable_url: true, if: [:external_import?, :import_url_changed?] allow_localhost: false,
ports: VALID_IMPORT_PORTS }, if: [:external_import?, :import_url_changed?]
validates :star_count, numericality: { greater_than_or_equal_to: 0 } validates :star_count, numericality: { greater_than_or_equal_to: 0 }
validate :check_limit, on: :create validate :check_limit, on: :create
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? } validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
......
...@@ -3,7 +3,7 @@ require 'carrierwave/orm/activerecord' ...@@ -3,7 +3,7 @@ require 'carrierwave/orm/activerecord'
class ProjectImportData < ActiveRecord::Base class ProjectImportData < ActiveRecord::Base
belongs_to :project, inverse_of: :import_data belongs_to :project, inverse_of: :import_data
attr_encrypted :credentials, attr_encrypted :credentials,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
marshal: true, marshal: true,
encode: true, encode: true,
mode: :per_attribute_iv_and_salt, mode: :per_attribute_iv_and_salt,
......
...@@ -3,7 +3,7 @@ class BambooService < CiService ...@@ -3,7 +3,7 @@ class BambooService < CiService
prop_accessor :bamboo_url, :build_key, :username, :password prop_accessor :bamboo_url, :build_key, :username, :password
validates :bamboo_url, presence: true, url: true, if: :activated? validates :bamboo_url, presence: true, public_url: true, if: :activated?
validates :build_key, presence: true, if: :activated? validates :build_key, presence: true, if: :activated?
validates :username, validates :username,
presence: true, presence: true,
......
class BugzillaService < IssueTrackerService class BugzillaService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated? validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
......
...@@ -8,7 +8,7 @@ class BuildkiteService < CiService ...@@ -8,7 +8,7 @@ class BuildkiteService < CiService
prop_accessor :project_url, :token prop_accessor :project_url, :token
boolean_accessor :enable_ssl_verification boolean_accessor :enable_ssl_verification
validates :project_url, presence: true, url: true, if: :activated? validates :project_url, presence: true, public_url: true, if: :activated?
validates :token, presence: true, if: :activated? validates :token, presence: true, if: :activated?
after_save :compose_service_hook, if: :activated? after_save :compose_service_hook, if: :activated?
......
...@@ -8,7 +8,7 @@ class ChatNotificationService < Service ...@@ -8,7 +8,7 @@ class ChatNotificationService < Service
prop_accessor :webhook, :username, :channel prop_accessor :webhook, :username, :channel
boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch
validates :webhook, presence: true, url: true, if: :activated? validates :webhook, presence: true, public_url: true, if: :activated?
def initialize_properties def initialize_properties
# Custom serialized properties initialization # Custom serialized properties initialization
......
class CustomIssueTrackerService < IssueTrackerService class CustomIssueTrackerService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated? validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
......
...@@ -4,7 +4,7 @@ class DroneCiService < CiService ...@@ -4,7 +4,7 @@ class DroneCiService < CiService
prop_accessor :drone_url, :token prop_accessor :drone_url, :token
boolean_accessor :enable_ssl_verification boolean_accessor :enable_ssl_verification
validates :drone_url, presence: true, url: true, if: :activated? validates :drone_url, presence: true, public_url: true, if: :activated?
validates :token, presence: true, if: :activated? validates :token, presence: true, if: :activated?
after_save :compose_service_hook, if: :activated? after_save :compose_service_hook, if: :activated?
......
class ExternalWikiService < Service class ExternalWikiService < Service
prop_accessor :external_wiki_url prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, url: true, if: :activated? validates :external_wiki_url, presence: true, public_url: true, if: :activated?
def title def title
'External Wiki' 'External Wiki'
......
class GitlabIssueTrackerService < IssueTrackerService class GitlabIssueTrackerService < IssueTrackerService
include Gitlab::Routing include Gitlab::Routing
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated? validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
......
...@@ -3,8 +3,8 @@ class JiraService < IssueTrackerService ...@@ -3,8 +3,8 @@ class JiraService < IssueTrackerService
include ApplicationHelper include ApplicationHelper
include ActionView::Helpers::AssetUrlHelper include ActionView::Helpers::AssetUrlHelper
validates :url, url: true, presence: true, if: :activated? validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, url: true, allow_blank: true validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated? validates :username, presence: true, if: :activated?
validates :password, presence: true, if: :activated? validates :password, presence: true, if: :activated?
......
...@@ -24,7 +24,7 @@ class KubernetesService < DeploymentService ...@@ -24,7 +24,7 @@ class KubernetesService < DeploymentService
prop_accessor :ca_pem prop_accessor :ca_pem
with_options presence: true, if: :activated? do with_options presence: true, if: :activated? do
validates :api_url, url: true validates :api_url, public_url: true
validates :token validates :token
end end
......
...@@ -3,7 +3,7 @@ class MockCiService < CiService ...@@ -3,7 +3,7 @@ class MockCiService < CiService
ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze ALLOWED_STATES = %w[failed canceled running pending success success_with_warnings skipped not_found].freeze
prop_accessor :mock_service_url prop_accessor :mock_service_url
validates :mock_service_url, presence: true, url: true, if: :activated? validates :mock_service_url, presence: true, public_url: true, if: :activated?
def title def title
'MockCI' 'MockCI'
......
...@@ -6,7 +6,7 @@ class PrometheusService < MonitoringService ...@@ -6,7 +6,7 @@ class PrometheusService < MonitoringService
boolean_accessor :manual_configuration boolean_accessor :manual_configuration
with_options presence: true, if: :manual_configuration? do with_options presence: true, if: :manual_configuration? do
validates :api_url, url: true validates :api_url, public_url: true
end end
before_save :synchronize_service_state before_save :synchronize_service_state
......
class RedmineService < IssueTrackerService class RedmineService < IssueTrackerService
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated? validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url prop_accessor :title, :description, :project_url, :issues_url, :new_issue_url
......
...@@ -3,7 +3,7 @@ class TeamcityService < CiService ...@@ -3,7 +3,7 @@ class TeamcityService < CiService
prop_accessor :teamcity_url, :build_type, :username, :password prop_accessor :teamcity_url, :build_type, :username, :password
validates :teamcity_url, presence: true, url: true, if: :activated? validates :teamcity_url, presence: true, public_url: true, if: :activated?
validates :build_type, presence: true, if: :activated? validates :build_type, presence: true, if: :activated?
validates :username, validates :username,
presence: true, presence: true,
......
...@@ -5,7 +5,7 @@ class RemoteMirror < ActiveRecord::Base ...@@ -5,7 +5,7 @@ class RemoteMirror < ActiveRecord::Base
UNPROTECTED_BACKOFF_DELAY = 5.minutes UNPROTECTED_BACKOFF_DELAY = 5.minutes
attr_encrypted :credentials, attr_encrypted :credentials,
key: Gitlab::Application.secrets.db_key_base, key: Settings.attr_encrypted_db_key_base,
marshal: true, marshal: true,
encode: true, encode: true,
mode: :per_attribute_iv_and_salt, mode: :per_attribute_iv_and_salt,
...@@ -17,7 +17,6 @@ class RemoteMirror < ActiveRecord::Base ...@@ -17,7 +17,6 @@ class RemoteMirror < ActiveRecord::Base
belongs_to :project, inverse_of: :remote_mirrors belongs_to :project, inverse_of: :remote_mirrors
validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true } validates :url, presence: true, url: { protocols: %w(ssh git http https), allow_blank: true }
validates :url, addressable_url: true, if: :url_changed?
before_save :set_new_remote_name, if: :mirror_url_changed? before_save :set_new_remote_name, if: :mirror_url_changed?
......
...@@ -2,8 +2,8 @@ class Timelog < ActiveRecord::Base ...@@ -2,8 +2,8 @@ class Timelog < ActiveRecord::Base
validates :time_spent, :user, presence: true validates :time_spent, :user, presence: true
validate :issuable_id_is_present validate :issuable_id_is_present
belongs_to :issue belongs_to :issue, touch: true
belongs_to :merge_request belongs_to :merge_request, touch: true
belongs_to :user belongs_to :user
def issuable def issuable
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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