diff options
66 files changed, 1459 insertions, 639 deletions
diff --git a/.eslintrc.yml b/.eslintrc.yml index 9eba5b2ad3b..5fc4af97e2d 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -30,6 +30,11 @@ rules: no-else-return: - error - allowElseIf: true + import/no-unresolved: + - error + - ignore: + # https://gitlab.com/gitlab-org/gitlab/issues/38226 + - '^ee_component/' import/no-useless-path-segments: off import/order: off lines-between-class-members: off diff --git a/Gemfile.lock b/Gemfile.lock index 054a491a019..cba3d118a7e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -748,7 +748,7 @@ GEM pry-rails (0.3.6) pry (>= 0.10.4) public_suffix (3.1.1) - puma (4.3.0) + puma (4.3.1) nio4r (~> 2.0) puma_worker_killer (0.1.1) get_process_mem (~> 0.2) diff --git a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js index 885247335a4..b4aac8eea2b 100644 --- a/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js +++ b/app/assets/javascripts/pages/projects/settings/ci_cd/show/index.js @@ -1,6 +1,7 @@ import initSettingsPanels from '~/settings_panels'; import SecretValues from '~/behaviors/secret_values'; import AjaxVariableList from '~/ci_variable_list/ajax_variable_list'; +import registrySettingsApp from '~/registry/settings/registry_settings_bundle'; document.addEventListener('DOMContentLoaded', () => { // Initialize expandable settings panels @@ -32,4 +33,6 @@ document.addEventListener('DOMContentLoaded', () => { if (instanceDefaultBadge) instanceDefaultBadge.style.display = 'none'; autoDevOpsExtraSettings.classList.toggle('hidden', !target.checked); }); + + registrySettingsApp(); }); diff --git a/app/assets/javascripts/registry/settings/components/registry_settings_app.vue b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue new file mode 100644 index 00000000000..b2c700b817c --- /dev/null +++ b/app/assets/javascripts/registry/settings/components/registry_settings_app.vue @@ -0,0 +1,43 @@ +<script> +import { mapState } from 'vuex'; +import { s__, sprintf } from '~/locale'; + +export default { + components: {}, + computed: { + ...mapState({ + helpPagePath: 'helpPagePath', + }), + + helpText() { + return sprintf( + s__( + 'PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}', + ), + { + helpLinkStart: `<a href="${this.helpPagePath}" target="_blank">`, + helpLinkEnd: '</a>', + }, + false, + ); + }, + }, +}; +</script> + +<template> + <div> + <p> + {{ s__('PackageRegistry|Tag retention policies are designed to:') }} + </p> + <ul> + <li>{{ s__('PackageRegistry|Keep and protect the images that matter most.') }}</li> + <li> + {{ + s__("PackageRegistry|Automatically remove extra images that aren't designed to be kept.") + }} + </li> + </ul> + <p ref="help-link" v-html="helpText"></p> + </div> +</template> diff --git a/app/assets/javascripts/registry/settings/registry_settings_bundle.js b/app/assets/javascripts/registry/settings/registry_settings_bundle.js new file mode 100644 index 00000000000..2938178ea86 --- /dev/null +++ b/app/assets/javascripts/registry/settings/registry_settings_bundle.js @@ -0,0 +1,24 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import store from './stores/'; +import RegistrySettingsApp from './components/registry_settings_app.vue'; + +Vue.use(Translate); + +export default () => { + const el = document.getElementById('js-registry-settings'); + if (!el) { + return null; + } + store.dispatch('setInitialState', el.dataset); + return new Vue({ + el, + store, + components: { + RegistrySettingsApp, + }, + render(createElement) { + return createElement('registry-settings-app', {}); + }, + }); +}; diff --git a/app/assets/javascripts/registry/settings/stores/actions.js b/app/assets/javascripts/registry/settings/stores/actions.js new file mode 100644 index 00000000000..f2c469d4edb --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/actions.js @@ -0,0 +1,6 @@ +import * as types from './mutation_types'; + +export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); + +// to avoid eslint error until more actions are added to the store +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/index.js b/app/assets/javascripts/registry/settings/stores/index.js new file mode 100644 index 00000000000..91a35aac149 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/index.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import mutations from './mutations'; +import state from './state'; + +Vue.use(Vuex); + +export const createStore = () => + new Vuex.Store({ + state, + actions, + mutations, + }); + +export default createStore(); diff --git a/app/assets/javascripts/registry/settings/stores/mutation_types.js b/app/assets/javascripts/registry/settings/stores/mutation_types.js new file mode 100644 index 00000000000..8a0f519eabd --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/mutation_types.js @@ -0,0 +1,4 @@ +export const SET_INITIAL_STATE = 'SET_INITIAL_STATE'; + +// to avoid eslint error until more actions are added to the store +export default () => {}; diff --git a/app/assets/javascripts/registry/settings/stores/mutations.js b/app/assets/javascripts/registry/settings/stores/mutations.js new file mode 100644 index 00000000000..4f32e11ed52 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/mutations.js @@ -0,0 +1,8 @@ +import * as types from './mutation_types'; + +export default { + [types.SET_INITIAL_STATE](state, initialState) { + state.helpPagePath = initialState.helpPagePath; + state.registrySettingsEndpoint = initialState.registrySettingsEndpoint; + }, +}; diff --git a/app/assets/javascripts/registry/settings/stores/state.js b/app/assets/javascripts/registry/settings/stores/state.js new file mode 100644 index 00000000000..4c0439458b6 --- /dev/null +++ b/app/assets/javascripts/registry/settings/stores/state.js @@ -0,0 +1,10 @@ +export default () => ({ + /* + * Help page path to generate the link + */ + helpPagePath: '', + /* + * Settings endpoint to call to fetch and update the settings + */ + registrySettingsEndpoint: '', +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue deleted file mode 100644 index 1873e09c370..00000000000 --- a/app/assets/javascripts/vue_merge_request_widget/components/deployment.vue +++ /dev/null @@ -1,245 +0,0 @@ -<script> -import { GlTooltipDirective } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; -import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; -import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; -import { __ } from '~/locale'; -import timeagoMixin from '../../vue_shared/mixins/timeago'; -import LoadingButton from '../../vue_shared/components/loading_button.vue'; -import { visitUrl } from '../../lib/utils/url_utility'; -import createFlash from '../../flash'; -import MemoryUsage from './memory_usage.vue'; -import StatusIcon from './mr_widget_status_icon.vue'; -import ReviewAppLink from './review_app_link.vue'; -import MRWidgetService from '../services/mr_widget_service'; - -export default { - // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives - // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings - name: 'Deployment', - components: { - LoadingButton, - MemoryUsage, - StatusIcon, - Icon, - TooltipOnTruncate, - FilteredSearchDropdown, - ReviewAppLink, - VisualReviewAppLink: () => - import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - mixins: [timeagoMixin], - props: { - deployment: { - type: Object, - required: true, - }, - showMetrics: { - type: Boolean, - required: true, - }, - showVisualReviewApp: { - type: Boolean, - required: false, - default: false, - }, - visualReviewAppMeta: { - type: Object, - required: false, - default: () => ({ - sourceProjectId: '', - sourceProjectPath: '', - mergeRequestId: '', - appUrl: '', - }), - }, - }, - deployedTextMap: { - running: __('Deploying to'), - success: __('Deployed to'), - failed: __('Failed to deploy to'), - created: __('Will deploy to'), - canceled: __('Failed to deploy to'), - }, - data() { - return { - isStopping: false, - }; - }, - computed: { - deployTimeago() { - return this.timeFormated(this.deployment.deployed_at); - }, - deploymentExternalUrl() { - if (this.deployment.changes && this.deployment.changes.length === 1) { - return this.deployment.changes[0].external_url; - } - return this.deployment.external_url; - }, - hasExternalUrls() { - return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); - }, - hasDeploymentTime() { - return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); - }, - hasDeploymentMeta() { - return Boolean(this.deployment.url && this.deployment.name); - }, - hasMetrics() { - return Boolean(this.deployment.metrics_url); - }, - deployedText() { - return this.$options.deployedTextMap[this.deployment.status]; - }, - isDeployInProgress() { - return this.deployment.status === 'running'; - }, - deployInProgressTooltip() { - return this.isDeployInProgress - ? __('Stopping this environment is currently not possible as a deployment is in progress') - : ''; - }, - shouldRenderDropdown() { - return this.deployment.changes && this.deployment.changes.length > 1; - }, - showMemoryUsage() { - return this.hasMetrics && this.showMetrics; - }, - }, - methods: { - stopEnvironment() { - const msg = __('Are you sure you want to stop this environment?'); - const isConfirmed = confirm(msg); // eslint-disable-line - - if (isConfirmed) { - this.isStopping = true; - - MRWidgetService.stopEnvironment(this.deployment.stop_url) - .then(res => res.data) - .then(data => { - if (data.redirect_url) { - visitUrl(data.redirect_url); - } - - this.isStopping = false; - }) - .catch(() => { - createFlash( - __('Something went wrong while stopping this environment. Please try again.'), - ); - this.isStopping = false; - }); - } - }, - }, -}; -</script> - -<template> - <div class="deploy-heading"> - <div class="ci-widget media"> - <div class="media-body"> - <div class="deploy-body"> - <div class="js-deployment-info deployment-info"> - <template v-if="hasDeploymentMeta"> - <span> {{ deployedText }} </span> - <tooltip-on-truncate - :title="deployment.name" - truncate-target="child" - class="deploy-link label-truncate" - > - <a - :href="deployment.url" - target="_blank" - rel="noopener noreferrer nofollow" - class="js-deploy-meta" - > - {{ deployment.name }} - </a> - </tooltip-on-truncate> - </template> - <span - v-if="hasDeploymentTime" - v-gl-tooltip - :title="deployment.deployed_at_formatted" - class="js-deploy-time" - > - {{ deployTimeago }} - </span> - <memory-usage - v-if="showMemoryUsage" - :metrics-url="deployment.metrics_url" - :metrics-monitoring-url="deployment.metrics_monitoring_url" - /> - </div> - <div> - <template v-if="hasExternalUrls"> - <filtered-search-dropdown - v-if="shouldRenderDropdown" - class="js-mr-wigdet-deployment-dropdown inline" - :items="deployment.changes" - :main-action-link="deploymentExternalUrl" - filter-key="path" - > - <template slot="mainAction" slot-scope="slotProps"> - <review-app-link - :link="deploymentExternalUrl" - :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" - /> - </template> - - <template slot="result" slot-scope="slotProps"> - <a - :href="slotProps.result.external_url" - target="_blank" - rel="noopener noreferrer nofollow" - class="menu-item" - > - <strong class="str-truncated-100 append-bottom-0 d-block"> - {{ slotProps.result.path }} - </strong> - - <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> - {{ slotProps.result.external_url }} - </p> - </a> - </template> - </filtered-search-dropdown> - <template v-else> - <review-app-link - :link="deploymentExternalUrl" - css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" - /> - </template> - <visual-review-app-link - v-if="showVisualReviewApp" - :link="deploymentExternalUrl" - :app-metadata="visualReviewAppMeta" - /> - </template> - <span - v-if="deployment.stop_url" - v-gl-tooltip - :title="deployInProgressTooltip" - class="d-inline-block" - tabindex="0" - > - <loading-button - :loading="isStopping" - :disabled="isDeployInProgress" - :title="__('Stop environment')" - container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4" - @click="stopEnvironment" - > - <icon name="stop" /> - </loading-button> - </span> - </div> - </div> - </div> - </div> - </div> -</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js new file mode 100644 index 00000000000..90741e3aa44 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/constants.js @@ -0,0 +1,8 @@ +// DEPLOYMENT STATUSES +export const CREATED = 'created'; +export const MANUAL_DEPLOY = 'manual_deploy'; +export const WILL_DEPLOY = 'will_deploy'; +export const RUNNING = 'running'; +export const SUCCESS = 'success'; +export const FAILED = 'failed'; +export const CANCELED = 'canceled'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue new file mode 100644 index 00000000000..e03b1e6d6a6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment.vue @@ -0,0 +1,108 @@ +<script> +import { GlTooltipDirective } from '@gitlab/ui'; +import DeploymentInfo from './deployment_info.vue'; +import DeploymentViewButton from './deployment_view_button.vue'; +import DeploymentStopButton from './deployment_stop_button.vue'; +import { MANUAL_DEPLOY, WILL_DEPLOY, CREATED, RUNNING, SUCCESS } from './constants'; + +export default { + // name: 'Deployment' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + name: 'Deployment', + components: { + DeploymentInfo, + DeploymentStopButton, + DeploymentViewButton, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + deployment: { + type: Object, + required: true, + }, + showMetrics: { + type: Boolean, + required: true, + }, + showVisualReviewApp: { + type: Boolean, + required: false, + default: false, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + sourceProjectPath: '', + mergeRequestId: '', + appUrl: '', + }), + }, + }, + computed: { + canBeManuallyDeployed() { + return this.computedDeploymentStatus === MANUAL_DEPLOY; + }, + computedDeploymentStatus() { + if (this.deployment.status === CREATED) { + return this.isManual ? MANUAL_DEPLOY : WILL_DEPLOY; + } + return this.deployment.status; + }, + hasExternalUrls() { + return Boolean(this.deployment.external_url && this.deployment.external_url_formatted); + }, + hasPreviousDeployment() { + return Boolean(!this.isCurrent && this.deployment.deployed_at); + }, + isCurrent() { + return this.computedDeploymentStatus === SUCCESS; + }, + isManual() { + return Boolean( + this.deployment.details && + this.deployment.details.playable_build && + this.deployment.details.playable_build.play_path, + ); + }, + isDeployInProgress() { + return this.deployment.status === RUNNING; + }, + }, +}; +</script> + +<template> + <div class="deploy-heading"> + <div class="ci-widget media"> + <div class="media-body"> + <div class="deploy-body"> + <deployment-info + :computed-deployment-status="computedDeploymentStatus" + :deployment="deployment" + :show-metrics="showMetrics" + /> + <div> + <!-- show appropriate version of review app button --> + <deployment-view-button + v-if="hasExternalUrls" + :is-current="isCurrent" + :deployment="deployment" + :show-visual-review-app="showVisualReviewApp" + :visual-review-app-metadata="visualReviewAppMeta" + /> + <!-- if it is stoppable, show stop --> + <deployment-stop-button + v-if="deployment.stop_url" + :is-deploy-in-progress="isDeployInProgress" + :stop-url="deployment.stop_url" + /> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue new file mode 100644 index 00000000000..47e70e972cf --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_info.vue @@ -0,0 +1,98 @@ +<script> +import { GlLink, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import MemoryUsage from './memory_usage.vue'; +import { MANUAL_DEPLOY, WILL_DEPLOY, RUNNING, SUCCESS, FAILED, CANCELED } from './constants'; + +export default { + name: 'DeploymentInfo', + components: { + GlLink, + MemoryUsage, + TooltipOnTruncate, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [timeagoMixin], + props: { + computedDeploymentStatus: { + type: String, + required: true, + }, + deployment: { + type: Object, + required: true, + }, + showMetrics: { + type: Boolean, + required: true, + }, + }, + deployedTextMap: { + [MANUAL_DEPLOY]: __('Can deploy manually to'), + [WILL_DEPLOY]: __('Will deploy to'), + [RUNNING]: __('Deploying to'), + [SUCCESS]: __('Deployed to'), + [FAILED]: __('Failed to deploy to'), + [CANCELED]: __('Canceled deploy to'), + }, + computed: { + deployTimeago() { + return this.timeFormated(this.deployment.deployed_at); + }, + deployedText() { + return this.$options.deployedTextMap[this.computedDeploymentStatus]; + }, + hasDeploymentTime() { + return Boolean(this.deployment.deployed_at && this.deployment.deployed_at_formatted); + }, + hasDeploymentMeta() { + return Boolean(this.deployment.url && this.deployment.name); + }, + hasMetrics() { + return Boolean(this.deployment.metrics_url); + }, + showMemoryUsage() { + return this.hasMetrics && this.showMetrics; + }, + }, +}; +</script> + +<template> + <div class="js-deployment-info deployment-info"> + <template v-if="hasDeploymentMeta"> + <span>{{ deployedText }}</span> + <tooltip-on-truncate + :title="deployment.name" + truncate-target="child" + class="deploy-link label-truncate" + > + <gl-link + :href="deployment.url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-meta gl-font-size-12" + > + {{ deployment.name }} + </gl-link> + </tooltip-on-truncate> + </template> + <span + v-if="hasDeploymentTime" + v-gl-tooltip + :title="deployment.deployed_at_formatted" + class="js-deploy-time" + > + {{ deployTimeago }} + </span> + <memory-usage + v-if="showMemoryUsage" + :metrics-url="deployment.metrics_url" + :metrics-monitoring-url="deployment.metrics_monitoring_url" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue new file mode 100644 index 00000000000..29e38648573 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_stop_button.vue @@ -0,0 +1,83 @@ +<script> +import { __ } from '~/locale'; +import { GlTooltipDirective } from '@gitlab/ui'; +import Icon from '~/vue_shared/components/icon.vue'; +import LoadingButton from '~/vue_shared/components/loading_button.vue'; +import { visitUrl } from '~/lib/utils/url_utility'; +import createFlash from '~/flash'; +import MRWidgetService from '../../services/mr_widget_service'; + +export default { + name: 'DeploymentStopButton', + components: { + LoadingButton, + Icon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + isDeployInProgress: { + type: Boolean, + required: true, + }, + stopUrl: { + type: String, + required: true, + }, + }, + data() { + return { + isStopping: false, + }; + }, + computed: { + deployInProgressTooltip() { + return this.isDeployInProgress + ? __('Stopping this environment is currently not possible as a deployment is in progress') + : ''; + }, + }, + methods: { + stopEnvironment() { + const msg = __('Are you sure you want to stop this environment?'); + const isConfirmed = confirm(msg); // eslint-disable-line + + if (isConfirmed) { + this.isStopping = true; + + MRWidgetService.stopEnvironment(this.stopUrl) + .then(res => res.data) + .then(data => { + if (data.redirect_url) { + visitUrl(data.redirect_url); + } + + this.isStopping = false; + }) + .catch(() => { + createFlash( + __('Something went wrong while stopping this environment. Please try again.'), + ); + this.isStopping = false; + }); + } + }, + }, +}; +</script> + +<template> + <span v-gl-tooltip :title="deployInProgressTooltip" class="d-inline-block" tabindex="0"> + <loading-button + v-gl-tooltip + :loading="isStopping" + :disabled="isDeployInProgress" + :title="__('Stop environment')" + container-class="js-stop-env btn btn-default btn-sm inline prepend-left-4" + @click="stopEnvironment" + > + <icon name="stop" /> + </loading-button> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue new file mode 100644 index 00000000000..9965e3d5203 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/deployment_view_button.vue @@ -0,0 +1,99 @@ +<script> +import FilteredSearchDropdown from '~/vue_shared/components/filtered_search_dropdown.vue'; +import ReviewAppLink from '../review_app_link.vue'; + +export default { + name: 'DeploymentViewButton', + components: { + FilteredSearchDropdown, + ReviewAppLink, + VisualReviewAppLink: () => + import('ee_component/vue_merge_request_widget/components/visual_review_app_link.vue'), + }, + props: { + deployment: { + type: Object, + required: true, + }, + isCurrent: { + type: Boolean, + required: true, + }, + showVisualReviewApp: { + type: Boolean, + required: false, + default: false, + }, + visualReviewAppMeta: { + type: Object, + required: false, + default: () => ({ + sourceProjectId: '', + sourceProjectPath: '', + mergeRequestId: '', + appUrl: '', + }), + }, + }, + computed: { + deploymentExternalUrl() { + if (this.deployment.changes && this.deployment.changes.length === 1) { + return this.deployment.changes[0].external_url; + } + return this.deployment.external_url; + }, + shouldRenderDropdown() { + return this.deployment.changes && this.deployment.changes.length > 1; + }, + }, +}; +</script> + +<template> + <span> + <filtered-search-dropdown + v-if="shouldRenderDropdown" + class="js-mr-wigdet-deployment-dropdown inline" + :items="deployment.changes" + :main-action-link="deploymentExternalUrl" + filter-key="path" + > + <template slot="mainAction" slot-scope="slotProps"> + <review-app-link + :is-current="isCurrent" + :link="deploymentExternalUrl" + :css-class="`deploy-link js-deploy-url inline ${slotProps.className}`" + /> + </template> + + <template slot="result" slot-scope="slotProps"> + <a + :href="slotProps.result.external_url" + target="_blank" + rel="noopener noreferrer nofollow" + class="js-deploy-url-menu-item menu-item" + > + <strong class="str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.path }} + </strong> + + <p class="text-secondary str-truncated-100 append-bottom-0 d-block"> + {{ slotProps.result.external_url }} + </p> + </a> + </template> + </filtered-search-dropdown> + <template v-else> + <review-app-link + :is-current="isCurrent" + :link="deploymentExternalUrl" + css-class="js-deploy-url deploy-link btn btn-default btn-sm inline" + /> + </template> + <visual-review-app-link + v-if="showVisualReviewApp" + :link="deploymentExternalUrl" + :app-metadata="visualReviewAppMeta" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue index 7113b72f8c5..fe41a15979e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/memory_usage.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/deployment/memory_usage.vue @@ -1,10 +1,10 @@ <script> import { sprintf, s__ } from '~/locale'; -import statusCodes from '../../lib/utils/http_status'; -import { bytesToMiB } from '../../lib/utils/number_utils'; -import { backOff } from '../../lib/utils/common_utils'; -import MemoryGraph from '../../vue_shared/components/memory_graph.vue'; -import MRWidgetService from '../services/mr_widget_service'; +import statusCodes from '~/lib/utils/http_status'; +import { bytesToMiB } from '~/lib/utils/number_utils'; +import { backOff } from '~/lib/utils/common_utils'; +import MemoryGraph from '~/vue_shared/components/memory_graph.vue'; +import MRWidgetService from '../../services/mr_widget_service'; export default { name: 'MemoryUsage', diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue index a297156ab10..c8b26889076 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline_container.vue @@ -1,7 +1,7 @@ <script> import _ from 'underscore'; import ArtifactsApp from './artifacts_list_app.vue'; -import Deployment from './deployment.vue'; +import Deployment from './deployment/deployment.vue'; import MrWidgetContainer from './mr_widget_container.vue'; import MrWidgetPipeline from './mr_widget_pipeline.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue index d2d32492e6c..1550ec0f21e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/review_app_link.vue @@ -1,4 +1,5 @@ <script> +import { __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; export default { @@ -6,15 +7,24 @@ export default { Icon, }, props: { - link: { + cssClass: { type: String, required: true, }, - cssClass: { + isCurrent: { + type: Boolean, + required: true, + }, + link: { type: String, required: true, }, }, + computed: { + linkText() { + return this.isCurrent ? __('View app') : __('View previous app'); + }, + }, }; </script> <template> @@ -26,6 +36,6 @@ export default { data-track-event="open_review_app" data-track-label="review_app" > - {{ __('View app') }} <icon class="fgray" name="external-link" /> + {{ linkText }} <icon class="fgray" name="external-link" /> </a> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index edd21a81f8b..363fe226f15 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -10,7 +10,7 @@ import createFlash from '../flash'; import WidgetHeader from './components/mr_widget_header.vue'; import WidgetMergeHelp from './components/mr_widget_merge_help.vue'; import MrWidgetPipelineContainer from './components/mr_widget_pipeline_container.vue'; -import Deployment from './components/deployment.vue'; +import Deployment from './components/deployment/deployment.vue'; import WidgetRelatedLinks from './components/mr_widget_related_links.vue'; import MrWidgetAlertMessage from './components/mr_widget_alert_message.vue'; import MergedState from './components/states/mr_widget_merged.vue'; diff --git a/app/controllers/projects/releases_controller.rb b/app/controllers/projects/releases_controller.rb index a9a264183b3..ffe69fe97e4 100644 --- a/app/controllers/projects/releases_controller.rb +++ b/app/controllers/projects/releases_controller.rb @@ -10,6 +10,7 @@ class Projects::ReleasesController < Projects::ApplicationController push_frontend_feature_flag(:release_evidence_collection, project) end before_action :authorize_update_release!, only: %i[edit update] + before_action :authorize_download_code!, only: [:evidence] def index respond_to do |format| diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1293b0d0f59..b8068a22c02 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -662,9 +662,8 @@ module Ci def execute_hooks return unless project - build_data = Gitlab::DataBuilder::Build.build(self) - project.execute_hooks(build_data.dup, :job_hooks) - project.execute_services(build_data.dup, :job_hooks) + project.execute_hooks(build_data.dup, :job_hooks) if project.has_active_hooks?(:job_hooks) + project.execute_services(build_data.dup, :job_hooks) if project.has_active_services?(:job_hooks) end def browsable_artifacts? @@ -873,6 +872,10 @@ module Ci private + def build_data + @build_data ||= Gitlab::DataBuilder::Build.build(self) + end + def successful_deployment_status if deployment&.last? :last diff --git a/app/services/cohorts_service.rb b/app/services/cohorts_service.rb index dbbe89ef260..03be87f4cc1 100644 --- a/app/services/cohorts_service.rb +++ b/app/services/cohorts_service.rb @@ -38,7 +38,7 @@ class CohortsService { registration_month: registration_month, - activity_months: activity_months, + activity_months: activity_months[1..-1], total: activity_months.first[:total], inactive: inactive } diff --git a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml index 6a7c999bff3..d4defd3f849 100644 --- a/app/views/instance_statistics/cohorts/_cohorts_table.html.haml +++ b/app/views/instance_statistics/cohorts/_cohorts_table.html.haml @@ -1,25 +1,32 @@ +- number_of_data_columns = @cohorts[:months_included] - 1 .bs-callout.clearfix %p - User cohorts are shown for the last #{@cohorts[:months_included]} - months. Only users with activity are counted in the cohort total; inactive - users are counted separately. + = s_("Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately.") % { months_included: @cohorts[:months_included] } = link_to icon('question-circle'), help_page_path('user/instance_statistics/user_cohorts', anchor: 'cohorts'), title: 'About this feature', target: '_blank' -.table-holder +.table-holder.d-xl-table %table.table %thead %tr - %th Registration month - %th Inactive users - %th Cohort total - - @cohorts[:months_included].times do |i| - %th Month #{i} + %th.border-right.pt-4{ colspan: 3 } + %th.font-weight-bold.pt-4{ colspan: number_of_data_columns } + = s_("Cohorts|Returning users") + %tr + %th.border-top-0 + = s_("Cohorts|Registration month") + %th.border-top-0 + = s_("Cohorts|Inactive users") + %th.border-top-0.border-right + = s_("Cohorts|New users") + - number_of_data_columns.times do |i| + %th.border-top-0 + = s_("Cohorts|Month %{month_index}") % { month_index: i + 1 } %tbody - @cohorts[:cohorts].each do |cohort| %tr %td= cohort[:registration_month] %td= cohort[:inactive] - %td= cohort[:total] + %td.border-right= cohort[:total] - cohort[:activity_months].each do |activity_month| %td - next if cohort[:total] == '0' diff --git a/app/views/projects/registry/settings/_index.haml b/app/views/projects/registry/settings/_index.haml new file mode 100644 index 00000000000..e1eed93664e --- /dev/null +++ b/app/views/projects/registry/settings/_index.haml @@ -0,0 +1,2 @@ +#js-registry-settings{ data: { registry_settings_endpoint: '', + help_page_path: help_page_path('user/project/operations/linking_to_an_external_dashboard') } } diff --git a/app/views/projects/settings/ci_cd/show.html.haml b/app/views/projects/settings/ci_cd/show.html.haml index d2265da26c3..38483f599b7 100644 --- a/app/views/projects/settings/ci_cd/show.html.haml +++ b/app/views/projects/settings/ci_cd/show.html.haml @@ -59,3 +59,14 @@ .settings-content = render 'projects/triggers/index' +- if Feature.enabled?(:registry_retention_policies_settings, @project) + %section.settings.no-animate#js-registry-polcies{ class: ('expanded' if expanded) } + .settings-header + %h4 + = _("Container Registry tag expiration policies") + %button.btn.js-settings-toggle{ type: 'button' } + = expanded ? _('Collapse') : _('Expand') + %p + = _("Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD.") + .settings-content + = render 'projects/registry/settings/index' diff --git a/changelogs/unreleased/18668-clean-up-cohorts.yml b/changelogs/unreleased/18668-clean-up-cohorts.yml new file mode 100644 index 00000000000..3cb8b304018 --- /dev/null +++ b/changelogs/unreleased/18668-clean-up-cohorts.yml @@ -0,0 +1,5 @@ +--- +title: Clean up the cohorts table +merge_request: 20779 +author: +type: changed diff --git a/changelogs/unreleased/25351-texts-and-structure.yml b/changelogs/unreleased/25351-texts-and-structure.yml new file mode 100644 index 00000000000..aa7c7205e32 --- /dev/null +++ b/changelogs/unreleased/25351-texts-and-structure.yml @@ -0,0 +1,5 @@ +--- +title: Update information and button text for deployment footer +merge_request: 18918 +author: +type: changed diff --git a/changelogs/unreleased/26019-restriction-on-evidence.yml b/changelogs/unreleased/26019-restriction-on-evidence.yml new file mode 100644 index 00000000000..21157eeecd3 --- /dev/null +++ b/changelogs/unreleased/26019-restriction-on-evidence.yml @@ -0,0 +1,5 @@ +--- +title: Evidence - Added restriction for guest on Release page +merge_request: 21102 +author: +type: changed diff --git a/changelogs/unreleased/sh-reduce-gitaly-calls-in-ci-build.yml b/changelogs/unreleased/sh-reduce-gitaly-calls-in-ci-build.yml new file mode 100644 index 00000000000..a9a940da48f --- /dev/null +++ b/changelogs/unreleased/sh-reduce-gitaly-calls-in-ci-build.yml @@ -0,0 +1,5 @@ +--- +title: Reduce Gitaly calls in BuildHooksWorker +merge_request: 20365 +author: +type: performance diff --git a/config/initializers/elastic_client_setup.rb b/config/initializers/elastic_client_setup.rb index a1abb29838b..f38b606b3a8 100644 --- a/config/initializers/elastic_client_setup.rb +++ b/config/initializers/elastic_client_setup.rb @@ -7,6 +7,17 @@ require 'gitlab/current_settings' Gitlab.ee do require 'elasticsearch/model' + ### Monkey patches + + Elasticsearch::Model::Response::Records.prepend GemExtensions::Elasticsearch::Model::Response::Records + Elasticsearch::Model::Adapter::Multiple::Records.prepend GemExtensions::Elasticsearch::Model::Adapter::Multiple::Records + Elasticsearch::Model::Indexing::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Indexing::InstanceMethods + Elasticsearch::Model::Adapter::ActiveRecord::Importing.prepend GemExtensions::Elasticsearch::Model::Adapter::ActiveRecord::Importing + Elasticsearch::Model::Client::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model::Client::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client + Elasticsearch::Model.singleton_class.prepend GemExtensions::Elasticsearch::Model::Client + ### Modified from elasticsearch-model/lib/elasticsearch/model.rb [ @@ -32,15 +43,4 @@ Gitlab.ee do target.respond_to?(:as_indexed_json) ? target.__send__(:as_indexed_json, options) : super end CODE - - ### Monkey patches - - Elasticsearch::Model::Response::Records.prepend GemExtensions::Elasticsearch::Model::Response::Records - Elasticsearch::Model::Adapter::Multiple::Records.prepend GemExtensions::Elasticsearch::Model::Adapter::Multiple::Records - Elasticsearch::Model::Indexing::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Indexing::InstanceMethods - Elasticsearch::Model::Adapter::ActiveRecord::Importing.prepend GemExtensions::Elasticsearch::Model::Adapter::ActiveRecord::Importing - Elasticsearch::Model::Client::InstanceMethods.prepend GemExtensions::Elasticsearch::Model::Client - Elasticsearch::Model::Client::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client - Elasticsearch::Model::ClassMethods.prepend GemExtensions::Elasticsearch::Model::Client - Elasticsearch::Model.singleton_class.prepend GemExtensions::Elasticsearch::Model::Client end diff --git a/config/initializers/zz_metrics.rb b/config/initializers/zz_metrics.rb index bc28780cc77..416611ef46c 100644 --- a/config/initializers/zz_metrics.rb +++ b/config/initializers/zz_metrics.rb @@ -88,8 +88,8 @@ def instrument_classes(instrumentation) instrumentation.instrument_instance_methods(Gitlab::Highlight) Gitlab.ee do - instrumentation.instrument_methods(Elasticsearch::Git::Repository) - instrumentation.instrument_instance_methods(Elasticsearch::Git::Repository) + instrumentation.instrument_instance_methods(Elastic::Latest::GitInstanceProxy) + instrumentation.instrument_instance_methods(Elastic::Latest::GitClassProxy) instrumentation.instrument_instance_methods(Search::GlobalService) instrumentation.instrument_instance_methods(Search::ProjectService) diff --git a/db/migrate/20191127151619_create_gitlab_subscription_histories.rb b/db/migrate/20191127151619_create_gitlab_subscription_histories.rb new file mode 100644 index 00000000000..718f2c1b313 --- /dev/null +++ b/db/migrate/20191127151619_create_gitlab_subscription_histories.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CreateGitlabSubscriptionHistories < ActiveRecord::Migration[5.2] + DOWNTIME = false + + def up + create_table :gitlab_subscription_histories do |t| + t.datetime_with_timezone :gitlab_subscription_created_at + t.datetime_with_timezone :gitlab_subscription_updated_at + t.date :start_date + t.date :end_date + t.date :trial_ends_on + t.integer :namespace_id, null: true + t.integer :hosted_plan_id, null: true + t.integer :max_seats_used + t.integer :seats + t.boolean :trial + t.integer :change_type, limit: 2 + t.bigint :gitlab_subscription_id, null: false + t.datetime_with_timezone :created_at + end + add_index :gitlab_subscription_histories, :gitlab_subscription_id + end + + def down + drop_table :gitlab_subscription_histories + end +end diff --git a/db/schema.rb b/db/schema.rb index c33223f8b59..c6fa84e96b5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1828,6 +1828,23 @@ ActiveRecord::Schema.define(version: 2019_12_02_031812) do t.index ["upload_id"], name: "index_geo_upload_deleted_events_on_upload_id" end + create_table "gitlab_subscription_histories", force: :cascade do |t| + t.datetime_with_timezone "gitlab_subscription_created_at" + t.datetime_with_timezone "gitlab_subscription_updated_at" + t.date "start_date" + t.date "end_date" + t.date "trial_ends_on" + t.integer "namespace_id" + t.integer "hosted_plan_id" + t.integer "max_seats_used" + t.integer "seats" + t.boolean "trial" + t.integer "change_type", limit: 2 + t.bigint "gitlab_subscription_id", null: false + t.datetime_with_timezone "created_at" + t.index ["gitlab_subscription_id"], name: "index_gitlab_subscription_histories_on_gitlab_subscription_id" + end + create_table "gitlab_subscriptions", force: :cascade do |t| t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "updated_at", null: false diff --git a/doc/administration/raketasks/maintenance.md b/doc/administration/raketasks/maintenance.md index e63e0c40393..149b8d62309 100644 --- a/doc/administration/raketasks/maintenance.md +++ b/doc/administration/raketasks/maintenance.md @@ -270,3 +270,15 @@ database: gitlabhq_production -------------------------------------------------- up migration_id migration_name ``` + +## Import common metrics + +Sometimes you may need to re-import the common metrics that power the Metrics dashboards. + +This could be as a result of [updating existing metrics](../../development/prometheus_metrics.md#update-existing-metrics), or as a [troubleshooting measure](../../user/project/integrations/prometheus.md#troubleshooting). + +To re-import the metrics you can run: + +```sh +sudo gitlab-rake metrics:setup_common_metrics +``` diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4a06f99f99e..714de3bac36 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -75,6 +75,7 @@ cannot be used as job names**: - `after_script` - `variables` - `cache` +- `include` ### Using reserved keywords diff --git a/doc/development/fe_guide/style/scss.md b/doc/development/fe_guide/style/scss.md index 02e7351d135..a8755e634be 100644 --- a/doc/development/fe_guide/style/scss.md +++ b/doc/development/fe_guide/style/scss.md @@ -249,7 +249,7 @@ scss_lint` in the GitLab directory. SCSS Lint will also run in GitLab CI to catch any warnings. If the Rake task is throwing warnings you don't understand, SCSS Lint's -documentation includes [a full list of their linters][scss-lint-documentation](https://github.com/sds/scss-lint/blob/master/lib/scss_lint/linter/README.md). +documentation includes [a full list of their linters](https://github.com/sds/scss-lint/blob/master/lib/scss_lint/linter/README.md). ### Fixing issues diff --git a/doc/development/prometheus_metrics.md b/doc/development/prometheus_metrics.md index b479c053862..74dbcddc5db 100644 --- a/doc/development/prometheus_metrics.md +++ b/doc/development/prometheus_metrics.md @@ -22,7 +22,9 @@ The requirement for adding a new metric is to make each query to have an unique ### Update existing metrics -After you add or change existing _common_ metric you have to create a new database migration that will query and update all existing metrics. +After you add or change an existing common metric, you must [re-run the import script](../administration/raketasks/maintenance.md#import-common-metrics) that will query and update all existing metrics. + +Or, you can create a database migration: NOTE: **Note:** If a query metric (which is identified by `id:`) is removed it will not be removed from database by default. diff --git a/doc/user/project/clusters/serverless/index.md b/doc/user/project/clusters/serverless/index.md index e827d848cce..529c3d9d141 100644 --- a/doc/user/project/clusters/serverless/index.md +++ b/doc/user/project/clusters/serverless/index.md @@ -348,7 +348,7 @@ project): echo-js: handler: echo-js source: ./echo-js - runtime: https://gitlab.com/gitlab-org/serverless/runtimes/nodejs + runtime: gitlab/runtimes/nodejs description: "node.js runtime function" environment: MY_FUNCTION: echo-js @@ -379,10 +379,27 @@ subsequent lines contain the function attributes. |-----------|-------------| | `handler` | The function's name. | | `source` | Directory with sources of a functions. | -| `runtime` (optional)| The runtime to be used to execute the function. When the runtime is not specified, we assume that `Dockerfile` is present in the function directory specified by `source`. | +| `runtime` (optional)| The runtime to be used to execute the function. This can be a runtime alias (see [Runtime aliases](#runtime-aliases)), or it can be a full URL to a custom runtime repository. When the runtime is not specified, we assume that `Dockerfile` is present in the function directory specified by `source`. | | `description` | A short description of the function. | | `environment` | Sets an environment variable for the specific function only. | +#### Runtime aliases + +The optional `runtime` parameter can refer to one of the following runtime aliases (also see [Supported runtimes](#supported-runtimes)): + +| Runtime alias | Maintained by | +|-------------|---------------| +| `gitlab/runtimes/go` | GitLab | +| `gitlab/runtimes/nodejs` | GitLab | +| `gitlab/runtimes/ruby` | GitLab | +| `openfaas/classic/csharp` | OpenFaaS | +| `openfaas/classic/go` | OpenFaaS | +| `openfaas/classic/node` | OpenFaaS | +| `openfaas/classic/php7` | OpenFaaS | +| `openfaas/classic/python` | OpenFaaS | +| `openfaas/classic/python3` | OpenFaaS | +| `openfaas/classic/ruby` | OpenFaaS | + After the `gitlab-ci.yml` template has been added and the `serverless.yml` file has been created, pushing a commit to your project will result in a CI pipeline being executed which will deploy each function as a Knative service. Once the diff --git a/doc/user/project/integrations/prometheus.md b/doc/user/project/integrations/prometheus.md index 25ba0a47a4e..de8aea6e468 100644 --- a/doc/user/project/integrations/prometheus.md +++ b/doc/user/project/integrations/prometheus.md @@ -574,6 +574,7 @@ If the "No data found" screen continues to appear, it could be due to: are not labeled correctly. To test this, connect to the Prometheus server and [run a query](prometheus_library/kubernetes.html#metrics-supported), replacing `$CI_ENVIRONMENT_SLUG` with the name of your environment. +- You may need to re-add the GitLab predefined common metrics. This can be done by running the [import common metrics rake task](../../../administration/raketasks/maintenance.md#import-common-metrics). [autodeploy]: ../../../topics/autodevops/index.md#auto-deploy [kubernetes]: https://kubernetes.io diff --git a/lib/api/entities.rb b/lib/api/entities.rb index d16786e47b2..1e41e28c449 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1319,7 +1319,7 @@ module API expose :milestones, using: Entities::Milestone, if: -> (release, _) { release.milestones.present? } expose :commit_path, expose_nil: false expose :tag_path, expose_nil: false - expose :evidence_sha, expose_nil: false + expose :evidence_sha, expose_nil: false, if: ->(_, _) { can_download_code? } expose :assets do expose :assets_count, as: :count do |release, _| assets_to_exclude = can_download_code? ? [] : [:sources] @@ -1329,7 +1329,7 @@ module API expose :links, using: Entities::Releases::Link do |release, options| release.links.sorted end - expose :evidence_file_path, expose_nil: false + expose :evidence_file_path, expose_nil: false, if: ->(_, _) { can_download_code? } end expose :_links do expose :merge_requests_url, expose_nil: false diff --git a/lib/tasks/gitlab/metrics.rake b/lib/tasks/gitlab/metrics.rake new file mode 100644 index 00000000000..8a57e400dbe --- /dev/null +++ b/lib/tasks/gitlab/metrics.rake @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +namespace :metrics do + desc "GitLab | Setup common metrics" + task setup_common_metrics: :gitlab_environment do + ::Gitlab::DatabaseImporters::CommonMetrics::Importer.new.execute + end +end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 41f0902147d..d556c699bd3 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2942,6 +2942,9 @@ msgstr "" msgid "Callback URL" msgstr "" +msgid "Can deploy manually to" +msgstr "" + msgid "Can override approvers and approvals required per merge request" msgstr "" @@ -2969,6 +2972,9 @@ msgstr "" msgid "Cancel this job" msgstr "" +msgid "Canceled deploy to" +msgstr "" + msgid "Cancelling Preview" msgstr "" @@ -4334,6 +4340,24 @@ msgstr "" msgid "Cohorts" msgstr "" +msgid "Cohorts|Inactive users" +msgstr "" + +msgid "Cohorts|Month %{month_index}" +msgstr "" + +msgid "Cohorts|New users" +msgstr "" + +msgid "Cohorts|Registration month" +msgstr "" + +msgid "Cohorts|Returning users" +msgstr "" + +msgid "Cohorts|User cohorts are shown for the last %{months_included} months. Only users with activity are counted in the 'New users' column; inactive users are counted separately." +msgstr "" + msgid "Collapse" msgstr "" @@ -4615,6 +4639,9 @@ msgstr "" msgid "Container Registry" msgstr "" +msgid "Container Registry tag expiration policies" +msgstr "" + msgid "Container Scanning" msgstr "" @@ -7092,6 +7119,9 @@ msgstr "" msgid "Expiration date" msgstr "" +msgid "Expiration policies for the Container Registry are a perfect solution for keeping the Registry space down while still enjoying the full power of GitLab CI/CD." +msgstr "" + msgid "Expired" msgstr "" @@ -12141,6 +12171,9 @@ msgstr "" msgid "Package was removed" msgstr "" +msgid "PackageRegistry|Automatically remove extra images that aren't designed to be kept." +msgstr "" + msgid "PackageRegistry|Copy Maven XML" msgstr "" @@ -12180,6 +12213,9 @@ msgstr "" msgid "PackageRegistry|Installation" msgstr "" +msgid "PackageRegistry|Keep and protect the images that matter most." +msgstr "" + msgid "PackageRegistry|Learn how to %{noPackagesLinkStart}publish and share your packages%{noPackagesLinkEnd} with GitLab." msgstr "" @@ -12192,12 +12228,18 @@ msgstr "" msgid "PackageRegistry|Package installation" msgstr "" +msgid "PackageRegistry|Read more about the %{helpLinkStart}Container Registry tag retention policies%{helpLinkEnd}" +msgstr "" + msgid "PackageRegistry|Registry Setup" msgstr "" msgid "PackageRegistry|Remove package" msgstr "" +msgid "PackageRegistry|Tag retention policies are designed to:" +msgstr "" + msgid "PackageRegistry|There are no packages yet" msgstr "" @@ -19533,6 +19575,9 @@ msgstr "" msgid "View open merge request" msgstr "" +msgid "View previous app" +msgstr "" + msgid "View project labels" msgstr "" diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index 349d73f13ca..ac13c0f2d9e 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -154,7 +154,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do .and_return(merge_request) end - it 'does not serialize builds in exposed stages', :sidekiq_might_not_need_inline do + it 'does not serialize builds in exposed stages' do get_show_json json_response.dig('pipeline', 'details', 'stages').tap do |stages| @@ -183,7 +183,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'job is cancelable' do let(:job) { create(:ci_build, :running, pipeline: pipeline) } - it 'cancel_path is present with correct redirect', :sidekiq_might_not_need_inline do + it 'cancel_path is present with correct redirect' do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') expect(json_response['cancel_path']).to include(CGI.escape(json_response['build_path'])) @@ -193,7 +193,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'with web terminal' do let(:job) { create(:ci_build, :running, :with_runner_session, pipeline: pipeline) } - it 'exposes the terminal path', :sidekiq_might_not_need_inline do + it 'exposes the terminal path' do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') expect(json_response['terminal_path']).to match(%r{/terminal}) @@ -268,7 +268,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do project.add_maintainer(user) # Need to be a maintianer to view cluster.path end - it 'exposes the deployment information', :sidekiq_might_not_need_inline do + it 'exposes the deployment information' do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -292,7 +292,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can edit runner', :sidekiq_might_not_need_inline do + it 'user can edit runner' do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -312,7 +312,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can not edit runner', :sidekiq_might_not_need_inline do + it 'user can not edit runner' do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -331,7 +331,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do sign_in(user) end - it 'user can not edit runner', :sidekiq_might_not_need_inline do + it 'user can not edit runner' do get_show_json expect(response).to have_gitlab_http_status(:ok) @@ -412,7 +412,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do context 'when job has trace' do let(:job) { create(:ci_build, :running, :trace_live, pipeline: pipeline) } - it "has_trace is true", :sidekiq_might_not_need_inline do + it "has_trace is true" do get_show_json expect(response).to match_response_schema('job/job_details') @@ -458,7 +458,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do create(:ci_pipeline_variable, pipeline: pipeline, key: :TRIGGER_KEY_1, value: 'TRIGGER_VALUE_1') end - context 'user is a maintainer', :sidekiq_might_not_need_inline do + context 'user is a maintainer' do before do project.add_maintainer(user) @@ -512,7 +512,7 @@ describe Projects::JobsController, :clean_gitlab_redis_shared_state do def get_show_json expect { get_show(id: job.id, format: :json) } - .not_to change { Gitlab::GitalyClient.get_request_count } + .to change { Gitlab::GitalyClient.get_request_count }.by(1) # ListCommitsByOid end def get_show(**extra_params) diff --git a/spec/controllers/projects/pipelines_controller_spec.rb b/spec/controllers/projects/pipelines_controller_spec.rb index 3c7f69f0e6e..b4549e4e635 100644 --- a/spec/controllers/projects/pipelines_controller_spec.rb +++ b/spec/controllers/projects/pipelines_controller_spec.rb @@ -93,7 +93,7 @@ describe Projects::PipelinesController do end context 'when performing gitaly calls', :request_store do - it 'limits the Gitaly requests', :sidekiq_might_not_need_inline do + it 'limits the Gitaly requests' do # Isolate from test preparation (Repository#exists? is also cached in RequestStore) RequestStore.end! RequestStore.clear! @@ -101,8 +101,9 @@ describe Projects::PipelinesController do expect(::Gitlab::GitalyClient).to receive(:allow_ref_name_caching).and_call_original + # ListCommitsByOid, RepositoryExists, HasLocalBranches expect { get_pipelines_index_json } - .to change { Gitlab::GitalyClient.get_request_count }.by(2) + .to change { Gitlab::GitalyClient.get_request_count }.by(3) end end diff --git a/spec/controllers/projects/releases_controller_spec.rb b/spec/controllers/projects/releases_controller_spec.rb index 5b528c3be52..6592d8d5702 100644 --- a/spec/controllers/projects/releases_controller_spec.rb +++ b/spec/controllers/projects/releases_controller_spec.rb @@ -184,19 +184,39 @@ describe Projects::ReleasesController do sign_in(user) end - it 'returns the correct evidence summary as a json' do - subject + context 'when the user is a developer' do + it 'returns the correct evidence summary as a json' do + subject + + expect(json_response).to eq(release.evidence.summary) + end - expect(json_response).to eq(release.evidence.summary) + context 'when the release was created before evidence existed' do + before do + release.evidence.destroy + end + + it 'returns an empty json' do + subject + + expect(json_response).to eq({}) + end + end end - context 'when the release was created before evidence existed' do - it 'returns an empty json' do - release.evidence.destroy + context 'when the user is a guest for the project' do + before do + project.add_guest(user) + end - subject + context 'when the project is private' do + let(:project) { private_project } + + it_behaves_like 'not found' + end - expect(json_response).to eq({}) + context 'when the project is public' do + it_behaves_like 'successful request' end end end diff --git a/spec/db/schema_spec.rb b/spec/db/schema_spec.rb index d4fab48b426..a42916a83a6 100644 --- a/spec/db/schema_spec.rb +++ b/spec/db/schema_spec.rb @@ -43,6 +43,7 @@ describe 'Database schema' do geo_nodes: %w[oauth_application_id], geo_repository_deleted_events: %w[project_id], geo_upload_deleted_events: %w[upload_id model_id], + gitlab_subscription_histories: %w[gitlab_subscription_id hosted_plan_id namespace_id], import_failures: %w[project_id], identities: %w[user_id], issues: %w[last_edited_by_id state_id], diff --git a/spec/features/merge_request/user_sees_deployment_widget_spec.rb b/spec/features/merge_request/user_sees_deployment_widget_spec.rb index cdffd2ae2f6..3743ef0f25d 100644 --- a/spec/features/merge_request/user_sees_deployment_widget_spec.rb +++ b/spec/features/merge_request/user_sees_deployment_widget_spec.rb @@ -96,7 +96,7 @@ describe 'Merge request > User sees deployment widget', :js do visit project_merge_request_path(project, merge_request) wait_for_requests - expect(page).to have_content("Failed to deploy to #{environment.name}") + expect(page).to have_content("Canceled deploy to #{environment.name}") expect(page).not_to have_css('.js-deploy-time') end end diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js index 6148f3c68f2..b6851a0e24c 100644 --- a/spec/frontend/issuables_list/components/issuable_spec.js +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -45,6 +45,7 @@ describe('Issuable component', () => { ...props, }, sync: false, + attachToDocument: true, }); }; diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js index e598a9c5a5d..dad4e74cb40 100644 --- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -49,6 +49,7 @@ describe('Issuables list component', () => { }, localVue, sync: false, + attachToDocument: true, }); }; diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap new file mode 100644 index 00000000000..c6dbb1da8e9 --- /dev/null +++ b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap @@ -0,0 +1,33 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Registry List renders 1`] = ` +<div> + <p> + + Tag retention policies are designed to: + + </p> + + <ul> + <li> + Keep and protect the images that matter most. + </li> + + <li> + + Automatically remove extra images that aren't designed to be kept. + + </li> + </ul> + + <p> + Read more about the + <a + href="foo" + target="_blank" + > + Container Registry tag retention policies + </a> + </p> +</div> +`; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js new file mode 100644 index 00000000000..666d970aa6b --- /dev/null +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -0,0 +1,40 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import component from '~/registry/settings/components/registry_settings_app.vue'; +import { createStore } from '~/registry/settings/stores/'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Registry List', () => { + let wrapper; + let store; + + const helpPagePath = 'foo'; + const findHelpLink = () => wrapper.find({ ref: 'help-link' }).find('a'); + + const mountComponent = (options = {}) => + shallowMount(component, { + sync: false, + store, + ...options, + }); + + beforeEach(() => { + store = createStore(); + store.dispatch('setInitialState', { helpPagePath }); + wrapper = mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('renders an help link dependant on the helphPagePath', () => { + expect(findHelpLink().attributes('href')).toBe(helpPagePath); + }); +}); diff --git a/spec/frontend/registry/settings/stores/actions_spec.js b/spec/frontend/registry/settings/stores/actions_spec.js new file mode 100644 index 00000000000..205c3a6ee21 --- /dev/null +++ b/spec/frontend/registry/settings/stores/actions_spec.js @@ -0,0 +1,20 @@ +import * as actions from '~/registry/settings/stores/actions'; +import * as types from '~/registry/settings/stores/mutation_types'; +import testAction from 'helpers/vuex_action_helper'; + +jest.mock('~/flash.js'); + +describe('Actions Registry Store', () => { + describe('setInitialState', () => { + it('should set the initial state', done => { + testAction( + actions.setInitialState, + 'foo', + {}, + [{ type: types.SET_INITIAL_STATE, payload: 'foo' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/registry/settings/stores/mutations_spec.js b/spec/frontend/registry/settings/stores/mutations_spec.js new file mode 100644 index 00000000000..421cd3f13cb --- /dev/null +++ b/spec/frontend/registry/settings/stores/mutations_spec.js @@ -0,0 +1,21 @@ +import mutations from '~/registry/settings/stores/mutations'; +import * as types from '~/registry/settings/stores/mutation_types'; +import createState from '~/registry/settings/stores/state'; + +describe('Mutations Registry Store', () => { + let mockState; + + beforeEach(() => { + mockState = createState(); + }); + + describe('SET_INITIAL_STATE', () => { + it('should set the initial state', () => { + const payload = { helpPagePath: 'foo', registrySettingsEndpoint: 'bar' }; + const expectedState = { ...mockState, ...payload }; + mutations[types.SET_INITIAL_STATE](mockState, payload); + + expect(mockState.endpoint).toEqual(expectedState.endpoint); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js new file mode 100644 index 00000000000..f8f4cb627dd --- /dev/null +++ b/spec/frontend/vue_mr_widget/deployment/deployment_mock_data.js @@ -0,0 +1,32 @@ +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; + +const deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/review-apps/environments/15', + stop_url: '/root/review-apps/environments/15/stop', + metrics_url: '/root/review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/review-apps/environments/15/metrics', + external_url: 'http://gitlab.com.', + external_url_formatted: 'gitlab', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + details: {}, + status: SUCCESS, + changes: [ + { + path: 'index.html', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', + }, + { + path: 'imgs/gallery.html', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + }, + { + path: 'about/', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', + }, + ], +}; + +export default deploymentMockData; diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js new file mode 100644 index 00000000000..78e086e473d --- /dev/null +++ b/spec/frontend/vue_mr_widget/deployment/deployment_spec.js @@ -0,0 +1,194 @@ +import { mount } from '@vue/test-utils'; +import DeploymentComponent from '~/vue_merge_request_widget/components/deployment/deployment.vue'; +import DeploymentInfo from '~/vue_merge_request_widget/components/deployment/deployment_info.vue'; +import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; +import DeploymentStopButton from '~/vue_merge_request_widget/components/deployment/deployment_stop_button.vue'; +import { + CREATED, + RUNNING, + SUCCESS, + FAILED, + CANCELED, +} from '~/vue_merge_request_widget/components/deployment/constants'; +import deploymentMockData from './deployment_mock_data'; + +const deployDetail = { + playable_build: { + retry_path: '/root/test-deployments/-/jobs/1131/retry', + play_path: '/root/test-deployments/-/jobs/1131/play', + }, + isManual: true, +}; + +describe('Deployment component', () => { + let wrapper; + + const factory = (options = {}) => { + // This destroys any wrappers created before a nested call to factory reassigns it + if (wrapper && wrapper.destroy) { + wrapper.destroy(); + } + wrapper = mount(DeploymentComponent, { + ...options, + }); + }; + + beforeEach(() => { + factory({ + propsData: { + deployment: deploymentMockData, + showMetrics: false, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('always renders DeploymentInfo', () => { + expect(wrapper.find(DeploymentInfo).exists()).toBe(true); + }); + + describe('status message and buttons', () => { + const noActions = []; + const noDetails = { isManual: false }; + const deployGroup = [DeploymentViewButton, DeploymentStopButton]; + + describe.each` + status | previous | deploymentDetails | text | actionButtons + ${CREATED} | ${true} | ${deployDetail} | ${'Can deploy manually to'} | ${deployGroup} + ${CREATED} | ${true} | ${noDetails} | ${'Will deploy to'} | ${deployGroup} + ${CREATED} | ${false} | ${deployDetail} | ${'Can deploy manually to'} | ${noActions} + ${CREATED} | ${false} | ${noDetails} | ${'Will deploy to'} | ${noActions} + ${RUNNING} | ${true} | ${deployDetail} | ${'Deploying to'} | ${deployGroup} + ${RUNNING} | ${true} | ${noDetails} | ${'Deploying to'} | ${deployGroup} + ${RUNNING} | ${false} | ${deployDetail} | ${'Deploying to'} | ${noActions} + ${RUNNING} | ${false} | ${noDetails} | ${'Deploying to'} | ${noActions} + ${SUCCESS} | ${true} | ${deployDetail} | ${'Deployed to'} | ${deployGroup} + ${SUCCESS} | ${true} | ${noDetails} | ${'Deployed to'} | ${deployGroup} + ${SUCCESS} | ${false} | ${deployDetail} | ${'Deployed to'} | ${deployGroup} + ${SUCCESS} | ${false} | ${noDetails} | ${'Deployed to'} | ${deployGroup} + ${FAILED} | ${true} | ${deployDetail} | ${'Failed to deploy to'} | ${deployGroup} + ${FAILED} | ${true} | ${noDetails} | ${'Failed to deploy to'} | ${deployGroup} + ${FAILED} | ${false} | ${deployDetail} | ${'Failed to deploy to'} | ${noActions} + ${FAILED} | ${false} | ${noDetails} | ${'Failed to deploy to'} | ${noActions} + ${CANCELED} | ${true} | ${deployDetail} | ${'Canceled deploy to'} | ${deployGroup} + ${CANCELED} | ${true} | ${noDetails} | ${'Canceled deploy to'} | ${deployGroup} + ${CANCELED} | ${false} | ${deployDetail} | ${'Canceled deploy to'} | ${noActions} + ${CANCELED} | ${false} | ${noDetails} | ${'Canceled deploy to'} | ${noActions} + `( + '$status + previous: $previous + manual: $deploymentDetails.isManual', + ({ status, previous, deploymentDetails, text, actionButtons }) => { + beforeEach(() => { + const previousOrSuccess = Boolean(previous || status === SUCCESS); + const updatedDeploymentData = { + status, + deployed_at: previous ? deploymentMockData.deployed_at : null, + deployed_at_formatted: previous ? deploymentMockData.deployed_at_formatted : null, + external_url: previousOrSuccess ? deploymentMockData.external_url : null, + external_url_formatted: previousOrSuccess + ? deploymentMockData.external_url_formatted + : null, + stop_url: previousOrSuccess ? deploymentMockData.stop_url : null, + details: deploymentDetails, + }; + + factory({ + propsData: { + showMetrics: false, + deployment: { + ...deploymentMockData, + ...updatedDeploymentData, + }, + }, + }); + }); + + it(`renders the text: ${text}`, () => { + expect(wrapper.find(DeploymentInfo).text()).toContain(text); + }); + + if (actionButtons.length > 0) { + describe('renders the expected button group', () => { + actionButtons.forEach(button => { + it(`renders ${button.name}`, () => { + expect(wrapper.find(button).exists()).toBe(true); + }); + }); + }); + } + + if (actionButtons.length === 0) { + describe('does not render the button group', () => { + [DeploymentViewButton, DeploymentStopButton].forEach(button => { + it(`does not render ${button.name}`, () => { + expect(wrapper.find(button).exists()).toBe(false); + }); + }); + }); + } + + if (actionButtons.includes(DeploymentViewButton)) { + it('renders the View button with expected text', () => { + if (status === SUCCESS) { + expect(wrapper.find(DeploymentViewButton).text()).toContain('View app'); + } else { + expect(wrapper.find(DeploymentViewButton).text()).toContain('View previous app'); + } + }); + } + }, + ); + }); + + describe('hasExternalUrls', () => { + describe('when deployment has both external_url_formatted and external_url', () => { + it('should return true', () => { + expect(wrapper.vm.hasExternalUrls).toEqual(true); + }); + + it('should render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(true); + }); + }); + + describe('when deployment has no external_url_formatted', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, external_url_formatted: null }, + showMetrics: false, + }, + }); + }); + + it('should return false', () => { + expect(wrapper.vm.hasExternalUrls).toEqual(false); + }); + + it('should not render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + }); + }); + + describe('when deployment has no external_url', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, external_url: null }, + showMetrics: false, + }, + }); + }); + + it('should return false', () => { + expect(wrapper.vm.hasExternalUrls).toEqual(false); + }); + + it('should not render the View Button', () => { + expect(wrapper.find(DeploymentViewButton).exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js new file mode 100644 index 00000000000..6e3c6f64c68 --- /dev/null +++ b/spec/frontend/vue_mr_widget/deployment/deployment_view_button_spec.js @@ -0,0 +1,118 @@ +import { mount, createLocalVue } from '@vue/test-utils'; +import DeploymentViewButton from '~/vue_merge_request_widget/components/deployment/deployment_view_button.vue'; +import ReviewAppLink from '~/vue_merge_request_widget/components/review_app_link.vue'; +import deploymentMockData from './deployment_mock_data'; + +describe('Deployment View App button', () => { + let wrapper; + + const factory = (options = {}) => { + const localVue = createLocalVue(); + + wrapper = mount(localVue.extend(DeploymentViewButton), { + localVue, + ...options, + }); + }; + + beforeEach(() => { + factory({ + propsData: { + deployment: deploymentMockData, + isCurrent: true, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('text', () => { + describe('when app is current', () => { + it('shows View app', () => { + expect(wrapper.find(ReviewAppLink).text()).toContain('View app'); + }); + }); + + describe('when app is not current', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: deploymentMockData, + isCurrent: false, + }, + }); + }); + + it('shows View Previous app', () => { + expect(wrapper.find(ReviewAppLink).text()).toContain('View previous app'); + }); + }); + }); + + describe('without changes', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, changes: null }, + isCurrent: false, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + }); + }); + + describe('with a single change', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: { ...deploymentMockData, changes: [deploymentMockData.changes[0]] }, + isCurrent: false, + }, + }); + }); + + it('renders the link to the review app without dropdown', () => { + expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(false); + }); + + it('renders the link to the review app linked to to the first change', () => { + const expectedUrl = deploymentMockData.changes[0].external_url; + const deployUrl = wrapper.find('.js-deploy-url'); + + expect(deployUrl.attributes().href).not.toBeNull(); + expect(deployUrl.attributes().href).toEqual(expectedUrl); + }); + }); + + describe('with multiple changes', () => { + beforeEach(() => { + factory({ + propsData: { + deployment: deploymentMockData, + isCurrent: false, + }, + }); + }); + + it('renders the link to the review app with dropdown', () => { + expect(wrapper.find('.js-mr-wigdet-deployment-dropdown').exists()).toBe(true); + }); + + it('renders all the links to the review apps', () => { + const allUrls = wrapper.findAll('.js-deploy-url-menu-item').wrappers; + const expectedUrls = deploymentMockData.changes.map(change => change.external_url); + + expectedUrls.forEach((expectedUrl, idx) => { + const deployUrl = allUrls[idx]; + + expect(deployUrl.attributes().href).not.toBeNull(); + expect(deployUrl.attributes().href).toEqual(expectedUrl); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/deployment_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_spec.js deleted file mode 100644 index 1949bee1406..00000000000 --- a/spec/javascripts/vue_mr_widget/components/deployment_spec.js +++ /dev/null @@ -1,313 +0,0 @@ -import Vue from 'vue'; -import deploymentComponent from '~/vue_merge_request_widget/components/deployment.vue'; -import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; -import { getTimeago } from '~/lib/utils/datetime_utility'; -import mountComponent from '../../helpers/vue_mount_component_helper'; - -describe('Deployment component', () => { - const Component = Vue.extend(deploymentComponent); - let deploymentMockData; - - beforeEach(() => { - deploymentMockData = { - id: 15, - name: 'review/diplo', - url: '/root/review-apps/environments/15', - stop_url: '/root/review-apps/environments/15/stop', - metrics_url: '/root/review-apps/environments/15/deployments/1/metrics', - metrics_monitoring_url: '/root/review-apps/environments/15/metrics', - external_url: 'http://gitlab.com.', - external_url_formatted: 'gitlab', - deployed_at: '2017-03-22T22:44:42.258Z', - deployed_at_formatted: 'Mar 22, 2017 10:44pm', - changes: [ - { - path: 'index.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', - }, - { - path: 'imgs/gallery.html', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', - }, - { - path: 'about/', - external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', - }, - ], - }; - }); - - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('', () => { - beforeEach(() => { - vm = mountComponent(Component, { deployment: { ...deploymentMockData }, showMetrics: true }); - }); - - describe('deployTimeago', () => { - it('return formatted date', () => { - const readable = getTimeago().format(deploymentMockData.deployed_at); - - expect(vm.deployTimeago).toEqual(readable); - }); - }); - - describe('hasExternalUrls', () => { - it('should return true', () => { - expect(vm.hasExternalUrls).toEqual(true); - }); - - it('should return false when deployment has no external_url_formatted', () => { - vm.deployment.external_url_formatted = null; - - expect(vm.hasExternalUrls).toEqual(false); - }); - - it('should return false when deployment has no external_url', () => { - vm.deployment.external_url = null; - - expect(vm.hasExternalUrls).toEqual(false); - }); - }); - - describe('hasDeploymentTime', () => { - it('should return true', () => { - expect(vm.hasDeploymentTime).toEqual(true); - }); - - it('should return false when deployment has no deployed_at', () => { - vm.deployment.deployed_at = null; - - expect(vm.hasDeploymentTime).toEqual(false); - }); - - it('should return false when deployment has no deployed_at_formatted', () => { - vm.deployment.deployed_at_formatted = null; - - expect(vm.hasDeploymentTime).toEqual(false); - }); - }); - - describe('hasDeploymentMeta', () => { - it('should return true', () => { - expect(vm.hasDeploymentMeta).toEqual(true); - }); - - it('should return false when deployment has no url', () => { - vm.deployment.url = null; - - expect(vm.hasDeploymentMeta).toEqual(false); - }); - - it('should return false when deployment has no name', () => { - vm.deployment.name = null; - - expect(vm.hasDeploymentMeta).toEqual(false); - }); - }); - - describe('stopEnvironment', () => { - const url = '/foo/bar'; - const returnPromise = () => - new Promise(resolve => { - resolve({ - data: { - redirect_url: url, - }, - }); - }); - const mockStopEnvironment = () => { - vm.stopEnvironment(deploymentMockData); - return vm; - }; - - it('should show a confirm dialog and call service.stopEnvironment when confirmed', done => { - spyOn(window, 'confirm').and.returnValue(true); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); - const visitUrl = spyOnDependency(deploymentComponent, 'visitUrl').and.returnValue(true); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); - setTimeout(() => { - expect(visitUrl).toHaveBeenCalledWith(url); - done(); - }, 333); - }); - - it('should show a confirm dialog but should not work if the dialog is rejected', () => { - spyOn(window, 'confirm').and.returnValue(false); - spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); - vm = mockStopEnvironment(); - - expect(window.confirm).toHaveBeenCalled(); - expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); - }); - }); - - it('renders deployment name', () => { - expect(vm.$el.querySelector('.js-deploy-meta').getAttribute('href')).toEqual( - deploymentMockData.url, - ); - - expect(vm.$el.querySelector('.js-deploy-meta').innerText).toContain(deploymentMockData.name); - }); - - it('renders external URL', () => { - expect(vm.$el.querySelector('.js-deploy-url').getAttribute('href')).toEqual( - deploymentMockData.external_url, - ); - - expect(vm.$el.querySelector('.js-deploy-url').innerText).toContain('View app'); - }); - - it('renders stop button', () => { - expect(vm.$el.querySelector('.btn')).not.toBeNull(); - }); - - it('renders deployment time', () => { - expect(vm.$el.querySelector('.js-deploy-time').innerText).toContain(vm.deployTimeago); - }); - - it('renders metrics component', () => { - expect(vm.$el.querySelector('.js-mr-memory-usage')).not.toBeNull(); - }); - }); - - describe('with showMetrics enabled', () => { - beforeEach(() => { - vm = mountComponent(Component, { deployment: { ...deploymentMockData }, showMetrics: true }); - }); - - it('shows metrics', () => { - expect(vm.$el).toContainElement('.js-mr-memory-usage'); - }); - }); - - describe('with showMetrics disabled', () => { - beforeEach(() => { - vm = mountComponent(Component, { deployment: { ...deploymentMockData }, showMetrics: false }); - }); - - it('hides metrics', () => { - expect(vm.$el).not.toContainElement('.js-mr-memory-usage'); - }); - }); - - describe('without changes', () => { - beforeEach(() => { - delete deploymentMockData.changes; - - vm = mountComponent(Component, { deployment: { ...deploymentMockData }, showMetrics: true }); - }); - - it('renders the link to the review app without dropdown', () => { - expect(vm.$el.querySelector('.js-mr-wigdet-deployment-dropdown')).toBeNull(); - expect(vm.$el.querySelector('.js-deploy-url')).not.toBeNull(); - }); - }); - - describe('with a single change', () => { - beforeEach(() => { - deploymentMockData.changes = deploymentMockData.changes.slice(0, 1); - - vm = mountComponent(Component, { - deployment: { ...deploymentMockData }, - showMetrics: true, - }); - }); - - it('renders the link to the review app without dropdown', () => { - expect(vm.$el.querySelector('.js-mr-wigdet-deployment-dropdown')).toBeNull(); - expect(vm.$el.querySelector('.js-deploy-url')).not.toBeNull(); - }); - - it('renders the link to the review app linked to to the first change', () => { - const expectedUrl = deploymentMockData.changes[0].external_url; - const deployUrl = vm.$el.querySelector('.js-deploy-url'); - - expect(vm.$el.querySelector('.js-mr-wigdet-deployment-dropdown')).toBeNull(); - expect(deployUrl).not.toBeNull(); - expect(deployUrl.href).toEqual(expectedUrl); - }); - }); - - describe('deployment status', () => { - describe('running', () => { - beforeEach(() => { - vm = mountComponent(Component, { - deployment: Object.assign({}, deploymentMockData, { status: 'running' }), - showMetrics: true, - }); - }); - - it('renders information about running deployment', () => { - expect(vm.$el.querySelector('.js-deployment-info').textContent).toContain('Deploying to'); - }); - - it('renders disabled stop button', () => { - expect(vm.$el.querySelector('.js-stop-env').getAttribute('disabled')).toBe('disabled'); - }); - }); - - describe('success', () => { - beforeEach(() => { - vm = mountComponent(Component, { - deployment: Object.assign({}, deploymentMockData, { status: 'success' }), - showMetrics: true, - }); - }); - - it('renders information about finished deployment', () => { - expect(vm.$el.querySelector('.js-deployment-info').textContent).toContain('Deployed to'); - }); - }); - - describe('failed', () => { - beforeEach(() => { - vm = mountComponent(Component, { - deployment: Object.assign({}, deploymentMockData, { status: 'failed' }), - showMetrics: true, - }); - }); - - it('renders information about finished deployment', () => { - expect(vm.$el.querySelector('.js-deployment-info').textContent).toContain( - 'Failed to deploy to', - ); - }); - }); - - describe('created', () => { - beforeEach(() => { - vm = mountComponent(Component, { - deployment: Object.assign({}, deploymentMockData, { status: 'created' }), - showMetrics: true, - }); - }); - - it('renders information about created deployment', () => { - expect(vm.$el.querySelector('.js-deployment-info').textContent).toContain('Will deploy to'); - }); - }); - - describe('canceled', () => { - beforeEach(() => { - vm = mountComponent(Component, { - deployment: Object.assign({}, deploymentMockData, { status: 'canceled' }), - showMetrics: true, - }); - }); - - it('renders information about canceled deployment', () => { - expect(vm.$el.querySelector('.js-deployment-info').textContent).toContain( - 'Failed to deploy to', - ); - }); - }); - }); -}); diff --git a/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js b/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js new file mode 100644 index 00000000000..6a6d8279c2c --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/deployment_stop_button_spec.js @@ -0,0 +1,95 @@ +import Vue from 'vue'; +import deploymentStopComponent from '~/vue_merge_request_widget/components/deployment/deployment_stop_button.vue'; +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; +import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Deployment component', () => { + const Component = Vue.extend(deploymentStopComponent); + let deploymentMockData; + + beforeEach(() => { + deploymentMockData = { + id: 15, + name: 'review/diplo', + url: '/root/review-apps/environments/15', + stop_url: '/root/review-apps/environments/15/stop', + metrics_url: '/root/review-apps/environments/15/deployments/1/metrics', + metrics_monitoring_url: '/root/review-apps/environments/15/metrics', + external_url: 'http://gitlab.com.', + external_url_formatted: 'gitlab', + deployed_at: '2017-03-22T22:44:42.258Z', + deployed_at_formatted: 'Mar 22, 2017 10:44pm', + deployment_manual_actions: [], + status: SUCCESS, + changes: [ + { + path: 'index.html', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/index.html', + }, + { + path: 'imgs/gallery.html', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/imgs/gallery.html', + }, + { + path: 'about/', + external_url: 'http://root-master-patch-91341.volatile-watch.surge.sh/about/', + }, + ], + }; + }); + + let vm; + + afterEach(() => { + vm.$destroy(); + }); + + describe('', () => { + beforeEach(() => { + vm = mountComponent(Component, { + stopUrl: deploymentMockData.stop_url, + isDeployInProgress: false, + }); + }); + + describe('stopEnvironment', () => { + const url = '/foo/bar'; + const returnPromise = () => + new Promise(resolve => { + resolve({ + data: { + redirect_url: url, + }, + }); + }); + const mockStopEnvironment = () => { + vm.stopEnvironment(deploymentMockData); + return vm; + }; + + it('should show a confirm dialog and call service.stopEnvironment when confirmed', done => { + spyOn(window, 'confirm').and.returnValue(true); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(true)); + const visitUrl = spyOnDependency(deploymentStopComponent, 'visitUrl').and.returnValue(true); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).toHaveBeenCalledWith(deploymentMockData.stop_url); + setTimeout(() => { + expect(visitUrl).toHaveBeenCalledWith(url); + done(); + }, 333); + }); + + it('should show a confirm dialog but should not work if the dialog is rejected', () => { + spyOn(window, 'confirm').and.returnValue(false); + spyOn(MRWidgetService, 'stopEnvironment').and.returnValue(returnPromise(false)); + vm = mockStopEnvironment(); + + expect(window.confirm).toHaveBeenCalled(); + expect(MRWidgetService.stopEnvironment).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js index 99de320ca2b..d15c3552b4a 100644 --- a/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_memory_usage_spec.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import MemoryUsage from '~/vue_merge_request_widget/components/memory_usage.vue'; +import MemoryUsage from '~/vue_merge_request_widget/components/deployment/memory_usage.vue'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; const url = '/root/acets-review-apps/environments/15/deployments/1/metrics'; diff --git a/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js b/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js index 069bc14e01e..d8d84d087bc 100644 --- a/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js +++ b/spec/javascripts/vue_mr_widget/components/review_app_link_spec.js @@ -8,6 +8,7 @@ describe('review app link', () => { const props = { link: '/review', cssClass: 'js-link', + isCurrent: true, }; let vm; let el; diff --git a/spec/javascripts/vue_mr_widget/mock_data.js b/spec/javascripts/vue_mr_widget/mock_data.js index 40bec001ffe..ff34dafa660 100644 --- a/spec/javascripts/vue_mr_widget/mock_data.js +++ b/spec/javascripts/vue_mr_widget/mock_data.js @@ -1,3 +1,5 @@ +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; + export default { id: 132, iid: 22, @@ -290,15 +292,20 @@ export const mockStore = { name: 'bogus', external_url: 'https://fake.com', external_url_formatted: 'https://fake.com', + status: SUCCESS, }, { id: 1, name: 'bogus-docs', external_url: 'https://fake.com', external_url_formatted: 'https://fake.com', + status: SUCCESS, }, ], - postMergeDeployments: [{ id: 0, name: 'prod' }, { id: 1, name: 'prod-docs' }], + postMergeDeployments: [ + { id: 0, name: 'prod', status: SUCCESS }, + { id: 1, name: 'prod-docs', status: SUCCESS }, + ], troubleshootingDocsPath: 'troubleshooting-docs-path', ciStatus: 'ci-status', hasCI: true, diff --git a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js index 30e0504e4e1..604b21e77fe 100644 --- a/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/javascripts/vue_mr_widget/mr_widget_options_spec.js @@ -6,6 +6,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mockData from './mock_data'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; +import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; const returnPromise = data => new Promise(resolve => { @@ -277,7 +278,9 @@ describe('mrWidgetOptions', () => { describe('fetchDeployments', () => { it('should fetch deployments', done => { - spyOn(vm.service, 'fetchDeployments').and.returnValue(returnPromise([{ id: 1 }])); + spyOn(vm.service, 'fetchDeployments').and.returnValue( + returnPromise([{ id: 1, status: SUCCESS }]), + ); vm.fetchPreMergeDeployments(); @@ -554,7 +557,7 @@ describe('mrWidgetOptions', () => { deployed_at: '2017-03-22T22:44:42.258Z', deployed_at_formatted: 'Mar 22, 2017 10:44pm', changes, - status: 'success', + status: SUCCESS, }; beforeEach(done => { diff --git a/spec/lib/api/entities/release_spec.rb b/spec/lib/api/entities/release_spec.rb new file mode 100644 index 00000000000..729a69347cb --- /dev/null +++ b/spec/lib/api/entities/release_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe API::Entities::Release do + let_it_be(:project) { create(:project) } + let_it_be(:release) { create(:release, :with_evidence, project: project) } + let(:user) { create(:user) } + let(:entity) { described_class.new(release, current_user: user) } + + subject { entity.as_json } + + describe 'evidence' do + context 'when the current user can download code' do + it 'exposes the evidence sha and the json path' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(user, :download_code, project).and_return(true) + + expect(subject[:evidence_sha]).to eq(release.evidence_sha) + expect(subject[:assets][:evidence_file_path]).to eq( + Gitlab::Routing.url_helpers.evidence_project_release_url(project, + release.tag, + format: :json) + ) + end + end + + context 'when the current user cannot download code' do + it 'does not expose any evidence data' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?) + .with(user, :download_code, project).and_return(false) + + expect(subject.keys).not_to include(:evidence_sha) + expect(subject[:assets].keys).not_to include(:evidence_file_path) + end + end + end +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 95435c6dabd..03340f34305 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -4063,4 +4063,54 @@ describe Ci::Build do expect(job.invalid_dependencies).to eq([pre_stage_job_invalid]) end end + + describe '#execute_hooks' do + context 'with project hooks' do + before do + create(:project_hook, project: project, job_events: true) + end + + it 'execute hooks' do + expect_any_instance_of(ProjectHook).to receive(:async_execute) + + build.execute_hooks + end + end + + context 'without relevant project hooks' do + before do + create(:project_hook, project: project, job_events: false) + end + + it 'does not execute a hook' do + expect_any_instance_of(ProjectHook).not_to receive(:async_execute) + + build.execute_hooks + end + end + + context 'with project services' do + before do + create(:service, active: true, job_events: true, project: project) + end + + it 'execute services' do + expect_any_instance_of(Service).to receive(:async_execute) + + build.execute_hooks + end + end + + context 'without relevant project services' do + before do + create(:service, active: true, job_events: false, project: project) + end + + it 'execute services' do + expect_any_instance_of(Service).not_to receive(:async_execute) + + build.execute_hooks + end + end + end end diff --git a/spec/services/cohorts_service_spec.rb b/spec/services/cohorts_service_spec.rb index 2c012f080dd..38f441fbc4d 100644 --- a/spec/services/cohorts_service_spec.rb +++ b/spec/services/cohorts_service_spec.rb @@ -22,73 +22,73 @@ describe CohortsService do expected_cohorts = [ { registration_month: month_start(11), - activity_months: Array.new(12) { { total: 0, percentage: 0 } }, + activity_months: Array.new(11) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(10), - activity_months: [{ total: 2, percentage: 100 }] + Array.new(10) { { total: 1, percentage: 50 } }, + activity_months: Array.new(10) { { total: 1, percentage: 50 } }, total: 2, inactive: 0 }, { registration_month: month_start(9), - activity_months: Array.new(10) { { total: 0, percentage: 0 } }, + activity_months: Array.new(9) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(8), - activity_months: [{ total: 2, percentage: 100 }] + Array.new(8) { { total: 1, percentage: 50 } }, + activity_months: Array.new(8) { { total: 1, percentage: 50 } }, total: 2, inactive: 0 }, { registration_month: month_start(7), - activity_months: Array.new(8) { { total: 0, percentage: 0 } }, + activity_months: Array.new(7) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(6), - activity_months: [{ total: 2, percentage: 100 }] + Array.new(6) { { total: 1, percentage: 50 } }, + activity_months: Array.new(6) { { total: 1, percentage: 50 } }, total: 2, inactive: 0 }, { registration_month: month_start(5), - activity_months: Array.new(6) { { total: 0, percentage: 0 } }, + activity_months: Array.new(5) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(4), - activity_months: [{ total: 2, percentage: 100 }] + Array.new(4) { { total: 1, percentage: 50 } }, + activity_months: Array.new(4) { { total: 1, percentage: 50 } }, total: 2, inactive: 0 }, { registration_month: month_start(3), - activity_months: Array.new(4) { { total: 0, percentage: 0 } }, + activity_months: Array.new(3) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(2), - activity_months: [{ total: 2, percentage: 100 }] + Array.new(2) { { total: 1, percentage: 50 } }, + activity_months: Array.new(2) { { total: 1, percentage: 50 } }, total: 2, inactive: 0 }, { registration_month: month_start(1), - activity_months: Array.new(2) { { total: 0, percentage: 0 } }, + activity_months: Array.new(1) { { total: 0, percentage: 0 } }, total: 0, inactive: 0 }, { registration_month: month_start(0), - activity_months: [{ total: 2, percentage: 100 }], + activity_months: [], total: 2, inactive: 1 } |