diff options
50 files changed, 1171 insertions, 295 deletions
diff --git a/.gitlab/merge_request_templates/Documentation.md b/.gitlab/merge_request_templates/Documentation.md index 63b50db08ea..303d3793ad8 100644 --- a/.gitlab/merge_request_templates/Documentation.md +++ b/.gitlab/merge_request_templates/Documentation.md @@ -1,13 +1,3 @@ -<!-- - Follow the documentation workflow https://docs.gitlab.com/ee/development/documentation/workflow.html - Additional information is located at https://docs.gitlab.com/ee/development/documentation/ - To find the designated Tech Writer for the stage/group, see - https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers - - Mention "documentation" or "docs" in the MR title - For changing documentation location use the Change Documentation Location.md template ---> - ## What does this MR do? <!-- Briefly describe what this MR is about. --> @@ -20,7 +10,8 @@ - Consider taking [the GitLab Technical Writing Fundamentals course](https://gitlab.edcast.com/pathways/ECL-02528ee2-c334-4e16-abf3-e9d8b8260de4) - [ ] Follow the: - - [Documentation Guidelines](https://docs.gitlab.com/ee/development/documentation/). + - [Documentation process](https://docs.gitlab.com/ee/development/documentation/workflow.html). + - [Documentation guidelines](https://docs.gitlab.com/ee/development/documentation/). - [Style Guide](https://docs.gitlab.com/ee/development/documentation/styleguide/). - [ ] Ensure that the [product tier badge](https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#product-tier-badges) is added to topic's `h1`. - [ ] [Request a review](https://docs.gitlab.com/ee/development/code_review.html#dogfooding-the-reviewers-feature) based on the: diff --git a/app/assets/javascripts/pages/projects/usage_quotas/index.js b/app/assets/javascripts/pages/projects/usage_quotas/index.js new file mode 100644 index 00000000000..9cd80b85c8a --- /dev/null +++ b/app/assets/javascripts/pages/projects/usage_quotas/index.js @@ -0,0 +1,23 @@ +import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; +import storageCounter from '~/projects/storage_counter'; +import initSearchSettings from '~/search_settings'; + +const initLinkedTabs = () => { + if (!document.querySelector('.js-usage-quota-tabs')) { + return false; + } + + return new LinkedTabs({ + defaultAction: '#storage-quota-tab', + parentEl: '.js-usage-quota-tabs', + hashedTabs: true, + }); +}; + +const initVueApp = () => { + storageCounter('js-project-storage-count-app'); +}; + +initVueApp(); +initLinkedTabs(); +initSearchSettings(); diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index 976ebe80aee..89b9091e6f9 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -56,7 +56,11 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers, { typeDefs, useGet: true }), + defaultClient: createDefaultClient(resolvers, { + typeDefs, + useGet: true, + assumeImmutableResults: true, + }), }); const { cache } = apolloProvider.clients.defaultClient; diff --git a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue index cff6f549b6e..8650db42076 100644 --- a/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue +++ b/app/assets/javascripts/pipeline_editor/pipeline_editor_app.vue @@ -134,7 +134,7 @@ export default { update(data) { const { ciConfig } = data || {}; const stageNodes = ciConfig?.stages?.nodes || []; - const stages = unwrapStagesWithNeeds(stageNodes); + const stages = unwrapStagesWithNeeds(JSON.parse(JSON.stringify(stageNodes))); return { ...ciConfig, stages }; }, diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js index 1d4ec4c110b..2505c47147f 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js @@ -5,7 +5,12 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient( + {}, + { + assumeImmutableResults: true, + }, + ), }); export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => { diff --git a/app/assets/javascripts/projects/storage_counter/components/app.vue b/app/assets/javascripts/projects/storage_counter/components/app.vue new file mode 100644 index 00000000000..19f9d29efaa --- /dev/null +++ b/app/assets/javascripts/projects/storage_counter/components/app.vue @@ -0,0 +1,13 @@ +<script> +import { s__ } from '~/locale'; + +export default { + name: 'StorageCounterApp', + i18n: { + placeholder: s__('UsageQuota|Usage'), + }, +}; +</script> +<template> + <div>{{ $options.i18n.placeholder }}</div> +</template> diff --git a/app/assets/javascripts/projects/storage_counter/index.js b/app/assets/javascripts/projects/storage_counter/index.js new file mode 100644 index 00000000000..915ee263063 --- /dev/null +++ b/app/assets/javascripts/projects/storage_counter/index.js @@ -0,0 +1,17 @@ +import Vue from 'vue'; +import StorageCounterApp from './components/app.vue'; + +export default (containerId = 'js-project-storage-count-app') => { + const el = document.getElementById(containerId); + + if (!el) { + return false; + } + + return new Vue({ + el, + render(createElement) { + return createElement(StorageCounterApp); + }, + }); +}; diff --git a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue index 23ecee449a4..fedd2519958 100644 --- a/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/runner/admin_runners/admin_runners_app.vue @@ -2,12 +2,16 @@ import createFlash from '~/flash'; import { fetchPolicies } from '~/lib/graphql'; import { updateHistory } from '~/lib/utils/url_utility'; +import { formatNumber, sprintf, __ } from '~/locale'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue'; -import { INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; +import { statusTokenConfig } from '../components/search_tokens/status_token_config'; +import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; +import { typeTokenConfig } from '../components/search_tokens/type_token_config'; +import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, I18N_FETCH_ERROR } from '../constants'; import getRunnersQuery from '../graphql/get_runners.query.graphql'; import { fromUrlQueryToSearch, @@ -78,6 +82,21 @@ export default { noRunnersFound() { return !this.runnersLoading && !this.runners.items.length; }, + activeRunnersMessage() { + return sprintf(__('Runners currently online: %{active_runners_count}'), { + active_runners_count: formatNumber(this.activeRunnersCount), + }); + }, + searchTokens() { + return [ + statusTokenConfig, + typeTokenConfig, + { + ...tagTokenConfig, + recentTokenValuesStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, + }, + ]; + }, }, watch: { search: { @@ -99,6 +118,7 @@ export default { captureException({ error, component: this.$options.name }); }, }, + filteredSearchNamespace: ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, }; </script> @@ -118,9 +138,13 @@ export default { <runner-filtered-search-bar v-model="search" - namespace="admin_runners" - :active-runners-count="activeRunnersCount" - /> + :tokens="searchTokens" + :namespace="$options.filteredSearchNamespace" + > + <template #runner-count> + {{ activeRunnersMessage }} + </template> + </runner-filtered-search-bar> <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> {{ __('No runners found') }} diff --git a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue index e14b3b17fa8..e04ca8ddca0 100644 --- a/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue +++ b/app/assets/javascripts/runner/components/runner_filtered_search_bar.vue @@ -1,27 +1,8 @@ <script> import { cloneDeep } from 'lodash'; -import { formatNumber, sprintf, __, s__ } from '~/locale'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { __ } from '~/locale'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { - STATUS_ACTIVE, - STATUS_PAUSED, - STATUS_ONLINE, - STATUS_OFFLINE, - STATUS_NOT_CONNECTED, - INSTANCE_TYPE, - GROUP_TYPE, - PROJECT_TYPE, - CREATED_DESC, - CREATED_ASC, - CONTACTED_DESC, - CONTACTED_ASC, - PARAM_KEY_STATUS, - PARAM_KEY_RUNNER_TYPE, - PARAM_KEY_TAG, -} from '../constants'; -import TagToken from './search_tokens/tag_token.vue'; +import { CREATED_DESC, CREATED_ASC, CONTACTED_DESC, CONTACTED_ASC } from '../constants'; const sortOptions = [ { @@ -58,10 +39,6 @@ export default { type: String, required: true, }, - activeRunnersCount: { - type: Number, - required: true, - }, }, data() { // filtered_search_bar_root.vue may mutate the inital @@ -73,62 +50,6 @@ export default { initialSortBy: sort, }; }, - computed: { - searchTokens() { - return [ - { - icon: 'status', - title: __('Status'), - type: PARAM_KEY_STATUS, - token: BaseToken, - unique: true, - options: [ - { value: STATUS_ACTIVE, title: s__('Runners|Active') }, - { value: STATUS_PAUSED, title: s__('Runners|Paused') }, - { value: STATUS_ONLINE, title: s__('Runners|Online') }, - { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, - - // Added extra quotes in this title to avoid splitting this value: - // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 - { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, - ], - // TODO In principle we could support more complex search rules, - // this can be added to a separate issue. - operators: OPERATOR_IS_ONLY, - }, - - { - icon: 'file-tree', - title: __('Type'), - type: PARAM_KEY_RUNNER_TYPE, - token: BaseToken, - unique: true, - options: [ - { value: INSTANCE_TYPE, title: s__('Runners|instance') }, - { value: GROUP_TYPE, title: s__('Runners|group') }, - { value: PROJECT_TYPE, title: s__('Runners|project') }, - ], - // TODO We should support more complex search rules, - // search for multiple states (OR) or have NOT operators - operators: OPERATOR_IS_ONLY, - }, - - { - icon: 'tag', - title: s__('Runners|Tags'), - type: PARAM_KEY_TAG, - token: TagToken, - recentTokenValuesStorageKey: `${this.namespace}-recent-tags`, - operators: OPERATOR_IS_ONLY, - }, - ]; - }, - activeRunnersMessage() { - return sprintf(__('Runners currently online: %{active_runners_count}'), { - active_runners_count: formatNumber(this.activeRunnersCount), - }); - }, - }, methods: { onFilter(filters) { const { sort } = this.value; @@ -161,12 +82,13 @@ export default { :sort-options="$options.sortOptions" :initial-filter-value="initialFilterValue" :initial-sort-by="initialSortBy" - :tokens="searchTokens" :search-input-placeholder="__('Search or filter results...')" data-testid="runners-filtered-search" @onFilter="onFilter" @onSort="onSort" /> - <div class="gl-text-right" data-testid="active-runners-message">{{ activeRunnersMessage }}</div> + <div class="gl-text-right" data-testid="runner-count"> + <slot name="runner-count"></slot> + </div> </div> </template> diff --git a/app/assets/javascripts/runner/components/search_tokens/status_token_config.js b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js new file mode 100644 index 00000000000..03dff5e61a5 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/status_token_config.js @@ -0,0 +1,32 @@ +import { __, s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { + STATUS_ACTIVE, + STATUS_PAUSED, + STATUS_ONLINE, + STATUS_OFFLINE, + STATUS_NOT_CONNECTED, + PARAM_KEY_STATUS, +} from '../../constants'; + +export const statusTokenConfig = { + icon: 'status', + title: __('Status'), + type: PARAM_KEY_STATUS, + token: BaseToken, + unique: true, + options: [ + { value: STATUS_ACTIVE, title: s__('Runners|Active') }, + { value: STATUS_PAUSED, title: s__('Runners|Paused') }, + { value: STATUS_ONLINE, title: s__('Runners|Online') }, + { value: STATUS_OFFLINE, title: s__('Runners|Offline') }, + + // Added extra quotes in this title to avoid splitting this value: + // see: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1438 + { value: STATUS_NOT_CONNECTED, title: `"${s__('Runners|Not connected')}"` }, + ], + // TODO In principle we could support more complex search rules, + // this can be added to a separate issue. + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue index 51fae60b6b7..ab67ac608e2 100644 --- a/app/assets/javascripts/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token.vue @@ -33,6 +33,7 @@ export default { // The API should // 1) scope to the rights of the user // 2) stay up to date to the removal of old tags + // 3) consider the scope of search, like searching within the tags of a group // See: https://gitlab.com/gitlab-org/gitlab/-/issues/333796 return axios .get(TAG_SUGGESTIONS_PATH, { diff --git a/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js b/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js new file mode 100644 index 00000000000..fdeba714385 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/tag_token_config.js @@ -0,0 +1,12 @@ +import { s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { PARAM_KEY_TAG } from '../../constants'; +import TagToken from './tag_token.vue'; + +export const tagTokenConfig = { + icon: 'tag', + title: s__('Runners|Tags'), + type: PARAM_KEY_TAG, + token: TagToken, + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/runner/components/search_tokens/type_token_config.js b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js new file mode 100644 index 00000000000..1da61c53386 --- /dev/null +++ b/app/assets/javascripts/runner/components/search_tokens/type_token_config.js @@ -0,0 +1,20 @@ +import { __, s__ } from '~/locale'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE, PARAM_KEY_RUNNER_TYPE } from '../../constants'; + +export const typeTokenConfig = { + icon: 'file-tree', + title: __('Type'), + type: PARAM_KEY_RUNNER_TYPE, + token: BaseToken, + unique: true, + options: [ + { value: INSTANCE_TYPE, title: s__('Runners|instance') }, + { value: GROUP_TYPE, title: s__('Runners|group') }, + { value: PROJECT_TYPE, title: s__('Runners|project') }, + ], + // TODO We should support more complex search rules, + // search for multiple states (OR) or have NOT operators + operators: OPERATOR_IS_ONLY, +}; diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js index 2822882e0cc..46e55b322c7 100644 --- a/app/assets/javascripts/runner/constants.js +++ b/app/assets/javascripts/runner/constants.js @@ -2,6 +2,7 @@ import { s__ } from '~/locale'; export const RUNNER_PAGE_SIZE = 20; export const RUNNER_JOB_COUNT_LIMIT = 1000; +export const GROUP_RUNNER_COUNT_LIMIT = 1000; export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.'); export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}'); @@ -50,3 +51,8 @@ export const CONTACTED_DESC = 'CONTACTED_DESC'; // TODO Add this to the API export const CONTACTED_ASC = 'CONTACTED_ASC'; export const DEFAULT_SORT = CREATED_DESC; + +// Local storage namespaces + +export const ADMIN_FILTERED_SEARCH_NAMESPACE = 'admin_runners'; +export const GROUP_FILTERED_SEARCH_NAMESPACE = 'group_runners'; diff --git a/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql new file mode 100644 index 00000000000..a601ee8d611 --- /dev/null +++ b/app/assets/javascripts/runner/graphql/get_group_runners.query.graphql @@ -0,0 +1,35 @@ +#import "~/runner/graphql/runner_node.fragment.graphql" +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getGroupRunners( + $groupFullPath: ID! + $before: String + $after: String + $first: Int + $last: Int + $status: CiRunnerStatus + $type: CiRunnerType + $search: String + $sort: CiRunnerSort +) { + group(fullPath: $groupFullPath) { + runners( + membership: DESCENDANTS + before: $before + after: $after + first: $first + last: $last + status: $status + type: $type + search: $search + sort: $sort + ) { + nodes { + ...RunnerNode + } + pageInfo { + ...PageInfo + } + } + } +} diff --git a/app/assets/javascripts/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/runner/group_runners/group_runners_app.vue index 07bbf60c453..42e1a9e1de9 100644 --- a/app/assets/javascripts/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/runner/group_runners/group_runners_app.vue @@ -1,18 +1,135 @@ <script> +import createFlash from '~/flash'; +import { fetchPolicies } from '~/lib/graphql'; +import { updateHistory } from '~/lib/utils/url_utility'; +import { formatNumber, sprintf, s__ } from '~/locale'; +import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; +import RunnerList from '../components/runner_list.vue'; import RunnerManualSetupHelp from '../components/runner_manual_setup_help.vue'; +import RunnerPagination from '../components/runner_pagination.vue'; import RunnerTypeHelp from '../components/runner_type_help.vue'; -import { GROUP_TYPE } from '../constants'; +import { statusTokenConfig } from '../components/search_tokens/status_token_config'; +import { typeTokenConfig } from '../components/search_tokens/type_token_config'; +import { + I18N_FETCH_ERROR, + GROUP_FILTERED_SEARCH_NAMESPACE, + GROUP_TYPE, + GROUP_RUNNER_COUNT_LIMIT, +} from '../constants'; +import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql'; +import { + fromUrlQueryToSearch, + fromSearchToUrl, + fromSearchToVariables, +} from '../runner_search_utils'; +import { captureException } from '../sentry_utils'; export default { + name: 'GroupRunnersApp', components: { + RunnerFilteredSearchBar, + RunnerList, RunnerManualSetupHelp, RunnerTypeHelp, + RunnerPagination, }, props: { registrationToken: { type: String, required: true, }, + groupFullPath: { + type: String, + required: true, + }, + groupRunnersLimitedCount: { + type: Number, + required: true, + }, + }, + data() { + return { + search: fromUrlQueryToSearch(), + runners: { + items: [], + pageInfo: {}, + }, + }; + }, + apollo: { + runners: { + query: getGroupRunnersQuery, + // Runners can be updated by users directly in this list. + // A "cache and network" policy prevents outdated filtered + // results. + fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, + variables() { + return this.variables; + }, + update(data) { + const { runners } = data?.group || {}; + return { + items: runners?.nodes || [], + pageInfo: runners?.pageInfo || {}, + }; + }, + error(error) { + createFlash({ message: I18N_FETCH_ERROR }); + + this.reportToSentry(error); + }, + }, + }, + computed: { + variables() { + return { + ...fromSearchToVariables(this.search), + groupFullPath: this.groupFullPath, + }; + }, + runnersLoading() { + return this.$apollo.queries.runners.loading; + }, + noRunnersFound() { + return !this.runnersLoading && !this.runners.items.length; + }, + groupRunnersCount() { + if (this.groupRunnersLimitedCount > GROUP_RUNNER_COUNT_LIMIT) { + return `${formatNumber(GROUP_RUNNER_COUNT_LIMIT)}+`; + } + return formatNumber(this.groupRunnersLimitedCount); + }, + runnerCountMessage() { + return sprintf(s__('Runners|Runners in this group: %{groupRunnersCount}'), { + groupRunnersCount: this.groupRunnersCount, + }); + }, + searchTokens() { + return [statusTokenConfig, typeTokenConfig]; + }, + filteredSearchNamespace() { + return `${GROUP_FILTERED_SEARCH_NAMESPACE}/${this.groupFullPath}`; + }, + }, + watch: { + search: { + deep: true, + handler() { + // TODO Implement back button reponse using onpopstate + updateHistory({ + url: fromSearchToUrl(this.search), + title: document.title, + }); + }, + }, + }, + errorCaptured(error) { + this.reportToSentry(error); + }, + methods: { + reportToSentry(error) { + captureException({ error, component: this.$options.name }); + }, }, GROUP_TYPE, }; @@ -31,5 +148,23 @@ export default { /> </div> </div> + + <runner-filtered-search-bar + v-model="search" + :tokens="searchTokens" + :namespace="filteredSearchNamespace" + > + <template #runner-count> + {{ runnerCountMessage }} + </template> + </runner-filtered-search-bar> + + <div v-if="noRunnersFound" class="gl-text-center gl-p-5"> + {{ __('No runners found') }} + </div> + <template v-else> + <runner-list :runners="runners.items" :loading="runnersLoading" /> + <runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> + </template> </div> </template> diff --git a/app/assets/javascripts/runner/group_runners/index.js b/app/assets/javascripts/runner/group_runners/index.js index e14c583d73e..9545764c68d 100644 --- a/app/assets/javascripts/runner/group_runners/index.js +++ b/app/assets/javascripts/runner/group_runners/index.js @@ -12,7 +12,13 @@ export const initGroupRunners = (selector = '#js-group-runners') => { return null; } - const { registrationToken, groupId } = el.dataset; + const { + registrationToken, + runnerInstallHelpPage, + groupId, + groupFullPath, + groupRunnersLimitedCount, + } = el.dataset; const apolloProvider = new VueApollo({ defaultClient: createDefaultClient( @@ -27,12 +33,15 @@ export const initGroupRunners = (selector = '#js-group-runners') => { el, apolloProvider, provide: { + runnerInstallHelpPage, groupId, }, render(h) { return h(GroupRunnersApp, { props: { registrationToken, + groupFullPath, + groupRunnersLimitedCount: parseInt(groupRunnersLimitedCount, 10), }, }); }, diff --git a/app/controllers/groups/runners_controller.rb b/app/controllers/groups/runners_controller.rb index ff3a09a2d2d..f37c08da22a 100644 --- a/app/controllers/groups/runners_controller.rb +++ b/app/controllers/groups/runners_controller.rb @@ -10,6 +10,8 @@ class Groups::RunnersController < Groups::ApplicationController feature_category :runner def index + finder = Ci::RunnersFinder.new(current_user: current_user, params: { group: @group }) + @group_runners_limited_count = finder.execute.except(:limit, :offset).page.total_count_with_limit(:all, limit: 1000) end def runner_list_group_view_vue_ui_enabled diff --git a/app/controllers/projects/usage_quotas_controller.rb b/app/controllers/projects/usage_quotas_controller.rb new file mode 100644 index 00000000000..6e2f7e45e8f --- /dev/null +++ b/app/controllers/projects/usage_quotas_controller.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class Projects::UsageQuotasController < Projects::ApplicationController + before_action :authorize_admin_project! + before_action :verify_usage_quotas_enabled! + + layout "project_settings" + + feature_category :utilization + + private + + def verify_usage_quotas_enabled! + render_404 unless Feature.enabled?(:project_storage_ui, project&.group, default_enabled: :yaml) + end +end diff --git a/app/views/groups/runners/index.html.haml b/app/views/groups/runners/index.html.haml index 4e7bc99b1f0..78cd8fa10f0 100644 --- a/app/views/groups/runners/index.html.haml +++ b/app/views/groups/runners/index.html.haml @@ -3,4 +3,4 @@ %h2.page-title = s_('Runners|Group Runners') -#js-group-runners{ data: { registration_token: @group.runners_token, group_id: @group.id } } +#js-group-runners{ data: { registration_token: @group.runners_token, runner_install_help_page: 'https://docs.gitlab.com/runner/install/', group_id: @group.id, group_full_path: @group.full_path, group_runners_limited_count: @group_runners_limited_count } } diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml new file mode 100644 index 00000000000..f45f8a2fd1d --- /dev/null +++ b/app/views/projects/usage_quotas/index.html.haml @@ -0,0 +1,17 @@ +- page_title s_("UsageQuota|Usage") + +%h3.page-title + = s_('UsageQuota|Usage Quotas') + +.row + .col-sm-6 + = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + +.top-area.scrolling-tabs-container.inner-page-scroll-tabs + %ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-usage-quota-tabs{ role: 'tablist' } + %li.nav-item + %a.nav-link#storage-quota{ data: { toggle: "tab", action: '#storage-quota-tab' }, href: '#storage-quota-tab', 'aria-controls': '#storage-quota-tab', 'aria-selected': 'true' } + = s_('UsageQuota|Storage') +.tab-content + .tab-pane#storage-quota-tab + #js-project-storage-count-app diff --git a/config/feature_flags/development/project_storage_ui.yml b/config/feature_flags/development/project_storage_ui.yml new file mode 100644 index 00000000000..23a5b5c3d29 --- /dev/null +++ b/config/feature_flags/development/project_storage_ui.yml @@ -0,0 +1,8 @@ +--- +name: project_storage_ui +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68289 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/334889 +milestone: '14.2' +type: development +group: group::utilization +default_enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index e71f1e1b028..34f8080ac23 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -251,6 +251,7 @@ Settings.gitlab_ci['url'] ||= Settings.__send__(:build_gitlab_ci # Settings['incoming_email'] ||= Settingslogic.new({}) Settings.incoming_email['enabled'] = false if Settings.incoming_email['enabled'].nil? +Settings.incoming_email['inbox_method'] ||= 'imap' # # Service desk email diff --git a/config/routes/project.rb b/config/routes/project.rb index 8ba9c100f71..8476f89142a 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -145,6 +145,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resource :packages_and_registries, only: [:show] end + resources :usage_quotas, only: [:index] + resources :autocomplete_sources, only: [] do collection do get 'members' diff --git a/doc/administration/geo/index.md b/doc/administration/geo/index.md index 7175d41abd8..d2d53f52418 100644 --- a/doc/administration/geo/index.md +++ b/doc/administration/geo/index.md @@ -214,7 +214,7 @@ For information on configuring Geo, see [Geo configuration](replication/configur ### Updating Geo -For information on how to update your Geo site(s) to the latest GitLab version, see [Updating the Geo sites](replication/updating_the_geo_nodes.md). +For information on how to update your Geo site(s) to the latest GitLab version, see [Updating the Geo sites](replication/updating_the_geo_sites.md). ### Pausing and resuming replication @@ -230,7 +230,7 @@ WARNING: Pausing and resuming of replication is currently only supported for Geo installations using an Omnibus GitLab-managed database. External databases are currently not supported. -In some circumstances, like during [upgrades](replication/updating_the_geo_nodes.md) or a [planned failover](disaster_recovery/planned_failover.md), it is desirable to pause replication between the primary and secondary. +In some circumstances, like during [upgrades](replication/updating_the_geo_sites.md) or a [planned failover](disaster_recovery/planned_failover.md), it is desirable to pause replication between the primary and secondary. Pausing and resuming replication is done via a command line tool from the a node in the secondary site where the `postgresql` service is enabled. diff --git a/doc/administration/geo/replication/configuration.md b/doc/administration/geo/replication/configuration.md index 5b22741f578..5f98e16e378 100644 --- a/doc/administration/geo/replication/configuration.md +++ b/doc/administration/geo/replication/configuration.md @@ -338,7 +338,7 @@ when: ## Upgrading Geo -See the [updating the Geo sites document](updating_the_geo_nodes.md). +See the [updating the Geo sites document](updating_the_geo_sites.md). ## Troubleshooting diff --git a/doc/administration/geo/replication/updating_the_geo_nodes.md b/doc/administration/geo/replication/updating_the_geo_nodes.md index 03570048071..f07c8d547a4 100644 --- a/doc/administration/geo/replication/updating_the_geo_nodes.md +++ b/doc/administration/geo/replication/updating_the_geo_nodes.md @@ -1,52 +1,9 @@ --- -stage: Enablement -group: Geo -info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments -type: howto +redirect_to: 'updating_the_geo_sites.md' +remove_date: '2021-11-23' --- -# Updating the Geo nodes **(PREMIUM SELF)** +This file was moved to [another location](updating_the_geo_sites.md). -WARNING: -Read these sections carefully before updating your Geo nodes. Not following -version-specific update steps may result in unexpected downtime. If you have -any specific questions, [contact Support](https://about.gitlab.com/support/#contact-support). - -Updating Geo nodes involves performing: - -1. [Version-specific update steps](version_specific_updates.md), depending on the - version being updated to or from. -1. [General update steps](#general-update-steps), for all updates. - -## General update steps - -NOTE: -These general update steps are not intended for [high-availability deployments](https://docs.gitlab.com/omnibus/update/README.html#multi-node--ha-deployment), and will cause downtime. If you want to avoid downtime, consider using [zero downtime updates](https://docs.gitlab.com/omnibus/update/README.html#zero-downtime-updates). - -To update the Geo nodes when a new GitLab version is released, update **primary** -and all **secondary** nodes: - -1. **Optional:** [Pause replication on each **secondary** node.](../index.md#pausing-and-resuming-replication) -1. Log into the **primary** node. -1. [Update GitLab on the **primary** node using Omnibus](https://docs.gitlab.com/omnibus/update/#update-using-the-official-repositories). -1. Log into each **secondary** node. -1. [Update GitLab on each **secondary** node using Omnibus](https://docs.gitlab.com/omnibus/update/#update-using-the-official-repositories). -1. If you paused replication in step 1, [resume replication on each **secondary**](../index.md#pausing-and-resuming-replication) -1. [Test](#check-status-after-updating) **primary** and **secondary** nodes, and check version in each. - -### Check status after updating - -Now that the update process is complete, you may want to check whether -everything is working correctly: - -1. Run the Geo Rake task on all nodes, everything should be green: - - ```shell - sudo gitlab-rake gitlab:geo:check - ``` - -1. Check the **primary** node's Geo dashboard for any errors. -1. Test the data replication by pushing code to the **primary** node and see if it - is received by **secondary** nodes. - -If you encounter any issues, see the [Geo troubleshooting guide](troubleshooting.md). +<!-- This redirect file can be deleted after <2021-11-23>. --> +<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/#move-or-rename-a-page --> diff --git a/doc/administration/geo/replication/updating_the_geo_sites.md b/doc/administration/geo/replication/updating_the_geo_sites.md new file mode 100644 index 00000000000..1213fd904ee --- /dev/null +++ b/doc/administration/geo/replication/updating_the_geo_sites.md @@ -0,0 +1,52 @@ +--- +stage: Enablement +group: Geo +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +type: howto +--- + +# Updating the Geo sites **(PREMIUM SELF)** + +WARNING: +Read these sections carefully before updating your Geo sites. Not following +version-specific update steps may result in unexpected downtime. If you have +any specific questions, [contact Support](https://about.gitlab.com/support/#contact-support). + +Updating Geo sites involves performing: + +1. [Version-specific update steps](version_specific_updates.md), depending on the + version being updated to or from. +1. [General update steps](#general-update-steps), for all updates. + +## General update steps + +NOTE: +These general update steps are not intended for [high-availability deployments](https://docs.gitlab.com/omnibus/update/README.html#multi-node--ha-deployment), and will cause downtime. If you want to avoid downtime, consider using [zero downtime updates](https://docs.gitlab.com/omnibus/update/README.html#zero-downtime-updates). + +To update the Geo sites when a new GitLab version is released, update **primary** +and all **secondary** sites: + +1. **Optional:** [Pause replication on each **secondary** sites.](../index.md#pausing-and-resuming-replication) +1. SSH into each node of the **primary** site. +1. [Update GitLab on the **primary** site using Omnibus](https://docs.gitlab.com/omnibus/update/#update-using-the-official-repositories). +1. SSH into each node of **secondary** sites. +1. [Update GitLab on each **secondary** site using Omnibus](https://docs.gitlab.com/omnibus/update/#update-using-the-official-repositories). +1. If you paused replication in step 1, [resume replication on each **secondary**](../index.md#pausing-and-resuming-replication) +1. [Test](#check-status-after-updating) **primary** and **secondary** sites, and check version in each. + +### Check status after updating + +Now that the update process is complete, you may want to check whether +everything is working correctly: + +1. Run the Geo Rake task on an application node for the primary and secondary sites. Everything should be green: + + ```shell + sudo gitlab-rake gitlab:geo:check + ``` + +1. Check the **primary** site's Geo dashboard for any errors. +1. Test the data replication by pushing code to the **primary** site and see if it + is received by **secondary** sites. + +If you encounter any issues, see the [Geo troubleshooting guide](troubleshooting.md). diff --git a/doc/administration/geo/replication/version_specific_updates.md b/doc/administration/geo/replication/version_specific_updates.md index 155c06f90b8..8a0badbdd63 100644 --- a/doc/administration/geo/replication/version_specific_updates.md +++ b/doc/administration/geo/replication/version_specific_updates.md @@ -8,7 +8,7 @@ type: howto # Version-specific update instructions **(PREMIUM SELF)** Review this page for update instructions for your version. These steps -accompany the [general steps](updating_the_geo_nodes.md#general-update-steps) +accompany the [general steps](updating_the_geo_sites.md#general-update-steps) for updating Geo nodes. ## Updating to GitLab 14.0/14.1 diff --git a/doc/administration/pages/index.md b/doc/administration/pages/index.md index 5aeb3eaef7f..4a8ec807d4b 100644 --- a/doc/administration/pages/index.md +++ b/doc/administration/pages/index.md @@ -742,6 +742,9 @@ database encryption. Proceed with caution. gitlab_pages['gitlab_server'] = 'http://<gitlab_server_IP_or_URL>' ``` +1. If you have custom UID/GID settings on the **GitLab server**, add them to the **Pages server** `/etc/gitlab/gitlab.rb` as well, + otherwise running a `gitlab-ctl reconfigure` on the **GitLab server** can change file ownership and cause Pages requests to fail. + 1. Create a backup of the secrets file on the **Pages server**: ```shell diff --git a/doc/api/discussions.md b/doc/api/discussions.md index 1c22f261e57..6d15c338f1c 100644 --- a/doc/api/discussions.md +++ b/doc/api/discussions.md @@ -15,7 +15,9 @@ Discussions are a set of related notes on: - Merge requests - Commits -This includes system notes, which are notes about changes to the object (for example, when a milestone changes, a corresponding system note is added). Label notes are not part of this API, but recorded as separate events in [resource label events](resource_label_events.md). +This includes system notes, which are notes about changes to the object (for example, +when a milestone changes, a corresponding system note is added). Label notes are +not part of this API, but recorded as separate events in [resource label events](resource_label_events.md). ## Discussions pagination @@ -118,7 +120,8 @@ GET /projects/:id/issues/:issue_iid/discussions ``` ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions" ``` ### Get single issue discussion item @@ -138,7 +141,8 @@ Parameters: | `discussion_id` | integer | yes | The ID of a discussion item | ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/<discussion_id>" ``` ### Create new issue thread @@ -159,7 +163,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions?body=comment" ``` ### Add note to existing issue thread @@ -185,7 +190,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/<discussion_id>/notes?body=comment" ``` ### Modify existing issue thread note @@ -207,7 +213,8 @@ Parameters: | `body` | string | yes | The content of the note/reply | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/<discussion_id>/notes/1108?body=comment" ``` ### Delete an issue thread note @@ -228,7 +235,8 @@ Parameters: | `note_id` | integer | yes | The ID of a discussion note | ```shell -curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/636" +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/issues/11/discussions/636" ``` ## Snippets @@ -326,7 +334,8 @@ GET /projects/:id/snippets/:snippet_id/discussions ``` ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions" ``` ### Get single snippet discussion item @@ -346,7 +355,8 @@ Parameters: | `discussion_id` | integer | yes | The ID of a discussion item | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/<discussion_id>" ``` ### Create new snippet thread @@ -368,7 +378,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions?body=comment" ``` ### Add note to existing snippet thread @@ -391,7 +402,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/<discussion_id>/notes?body=comment" ``` ### Modify existing snippet thread note @@ -413,7 +425,8 @@ Parameters: | `body` | string | yes | The content of the note/reply | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/<discussion_id>/notes/1108?body=comment" ``` ### Delete a snippet thread note @@ -434,7 +447,8 @@ Parameters: | `note_id` | integer | yes | The ID of a discussion note | ```shell -curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636" +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/snippets/11/discussions/636" ``` ## Epics **(ULTIMATE)** @@ -533,7 +547,8 @@ GET /groups/:id/epics/:epic_id/discussions ``` ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions" ``` ### Get single epic discussion item @@ -553,7 +568,8 @@ Parameters: | `discussion_id` | integer | yes | The ID of a discussion item | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/<discussion_id>" ``` ### Create new epic thread @@ -575,7 +591,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions?body=comment" ``` ### Add note to existing epic thread @@ -599,7 +616,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/<discussion_id>/notes?body=comment" ``` ### Modify existing epic thread note @@ -621,7 +639,8 @@ Parameters: | `body` | string | yes | The content of note/reply | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/<discussion_id>/notes/1108?body=comment" ``` ### Delete an epic thread note @@ -642,7 +661,8 @@ Parameters: | `note_id` | integer | yes | The ID of a thread note | ```shell -curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/636" +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/groups/5/epics/11/discussions/636" ``` ## Merge requests @@ -805,7 +825,8 @@ Diff comments also contain position: ``` ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions" ``` ### Get single merge request discussion item @@ -825,7 +846,8 @@ Parameters: | `discussion_id` | integer | yes | The ID of a discussion item | ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7 +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/<discussion_id>" ``` ### Create new merge request thread @@ -866,57 +888,67 @@ Parameters for all comments: #### Create a new thread on the overview page ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions?body=comment" ``` #### Create a new thread in the merge request diff -- Both `position[old_path]` and `position[new_path]` are required and must refer to the file path before and after the change. -- To create a thread on an added line (highlighted in green in the merge request diff), use `position[new_line]` and don't include `position[old_line]`. -- To create a thread on a removed line (highlighted in red in the merge request diff), use `position[old_line]` and don't include `position[new_line]`. -- To create a thread on an unchanged line, include both `position[new_line]` and `position[old_line]` for the line. These positions might not be the same if earlier changes in the file changed the line number. This is a bug that we plan to fix in [GraphQL `createDiffNote` forces clients to compute redundant information (#325161)](https://gitlab.com/gitlab-org/gitlab/-/issues/325161). -- If you specify incorrect `base`/`head`/`start` `SHA` parameters, you might run into the following bug: [Merge request comments receive "download" link instead of inline code (#296829)](https://gitlab.com/gitlab-org/gitlab/-/issues/296829). +- Both `position[old_path]` and `position[new_path]` are required and must refer + to the file path before and after the change. +- To create a thread on an added line (highlighted in green in the merge request diff), + use `position[new_line]` and don't include `position[old_line]`. +- To create a thread on a removed line (highlighted in red in the merge request diff), + use `position[old_line]` and don't include `position[new_line]`. +- To create a thread on an unchanged line, include both `position[new_line]` and + `position[old_line]` for the line. These positions might not be the same if earlier + changes in the file changed the line number. This is a bug that we plan to fix in + [GraphQL `createDiffNote` forces clients to compute redundant information (#325161)](https://gitlab.com/gitlab-org/gitlab/-/issues/325161). +- If you specify incorrect `base`/`head`/`start` `SHA` parameters, you might run + into the following bug: + [Merge request comments receive "download" link instead of inline code (#296829)](https://gitlab.com/gitlab-org/gitlab/-/issues/296829). To create a new thread: 1. [Get the latest merge request version](merge_requests.md#get-mr-diff-versions): - ```shell - curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/versions" - ```` + ```shell + curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/versions" + ``` 1. Note the details of the latest version, which is listed first in the response array. - ```json - [ - { - "id": 164560414, - "head_commit_sha": "f9ce7e16e56c162edbc9e480108041cf6b0291fe", - "base_commit_sha": "5e6dffa282c5129aa67cd227a0429be21bfdaf80", - "start_commit_sha": "5e6dffa282c5129aa67cd227a0429be21bfdaf80", - "created_at": "2021-03-30T09:18:27.351Z", - "merge_request_id": 93958054, - "state": "collected", - "real_size": "2" - }, - "previous versions are here" - ] - ``` + ```json + [ + { + "id": 164560414, + "head_commit_sha": "f9ce7e16e56c162edbc9e480108041cf6b0291fe", + "base_commit_sha": "5e6dffa282c5129aa67cd227a0429be21bfdaf80", + "start_commit_sha": "5e6dffa282c5129aa67cd227a0429be21bfdaf80", + "created_at": "2021-03-30T09:18:27.351Z", + "merge_request_id": 93958054, + "state": "collected", + "real_size": "2" + }, + "previous versions are here" + ] + ``` 1. Create a new diff thread. This example creates a thread on an added line: - ```shell - curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ - --form 'position[position_type]=text'\ - --form 'position[base_sha]=<use base_commit_sha from the versions response>'\ - --form 'position[head_sha]=<use head_commit_sha from the versions response>'\ - --form 'position[start_sha]=<use start_commit_sha from the versions response>'\ - --form 'position[new_path]=file.js'\ - --form 'position[old_path]=file.js'\ - --form 'position[new_line]=18'\ - --form 'body=test comment body'\ - "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions" - ``` + ```shell + curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + --form 'position[position_type]=text'\ + --form 'position[base_sha]=<use base_commit_sha from the versions response>'\ + --form 'position[head_sha]=<use head_commit_sha from the versions response>'\ + --form 'position[start_sha]=<use start_commit_sha from the versions response>'\ + --form 'position[new_path]=file.js'\ + --form 'position[old_path]=file.js'\ + --form 'position[new_line]=18'\ + --form 'body=test comment body'\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions" + ``` #### Parameters for multiline comments @@ -960,7 +992,8 @@ Parameters: | `resolved` | boolean | yes | Resolve/unresolve the discussion | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7?resolved=true" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/<discussion_id>?resolved=true" ``` ### Add note to existing merge request thread @@ -984,7 +1017,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such as `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/<discussion_id>/notes?body=comment" ``` ### Modify an existing merge request thread note @@ -1007,13 +1041,15 @@ Parameters: | `resolved` | boolean | no | Resolve/unresolve the note (exactly one of `body` or `resolved` must be set | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/<discussion_id>/notes/1108?body=comment" ``` Resolving a note: ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?resolved=true" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/<discussion_id>/notes/1108?resolved=true" ``` ### Delete a merge request thread note @@ -1034,7 +1070,8 @@ Parameters: | `note_id` | integer | yes | The ID of a thread note | ```shell -curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/636" +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/merge_requests/11/discussions/636" ``` ## Commits @@ -1177,7 +1214,8 @@ Diff comments contain also position: ``` ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions" ``` ### Get single commit discussion item @@ -1197,7 +1235,8 @@ Parameters: | `discussion_id` | integer | yes | The ID of a discussion item | ```shell -curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7" +curl --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/<discussion_id>" ``` ### Create new commit thread @@ -1232,7 +1271,8 @@ Parameters: | `position[y]` | integer | no | Y coordinate (for `image` diff notes) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions?body=comment" +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions?body=comment" ``` The rules for creating the API request are the same as when @@ -1259,7 +1299,8 @@ Parameters: | `created_at` | string | no | Date time string, ISO 8601 formatted, such `2016-03-11T03:45:40Z` (requires administrator or project/group owner rights) | ```shell -curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes?body=comment +curl --request POST --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/<discussion_id>/notes?body=comment ``` ### Modify an existing commit thread note @@ -1281,13 +1322,15 @@ Parameters: | `body` | string | no | The content of a note | ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?body=comment" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/<discussion_id>/notes/1108?body=comment" ``` Resolving a note: ```shell -curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/6a9c1750b37d513a43987b574953fceb50b03ce7/notes/1108?resolved=true" +curl --request PUT --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/<discussion_id>/notes/1108?resolved=true" ``` ### Delete a commit thread note @@ -1308,5 +1351,6 @@ Parameters: | `note_id` | integer | yes | The ID of a thread note | ```shell -curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/636" +curl --request DELETE --header "PRIVATE-TOKEN: <your_access_token>"\ + "https://gitlab.example.com/api/v4/projects/5/commits/11/discussions/636" ``` diff --git a/doc/architecture/blueprints/database/scalability/patterns/img/db_terminology_v14_2.png b/doc/architecture/blueprints/database/scalability/patterns/img/db_terminology_v14_2.png Binary files differdeleted file mode 100644 index 85ba1360f06..00000000000 --- a/doc/architecture/blueprints/database/scalability/patterns/img/db_terminology_v14_2.png +++ /dev/null diff --git a/doc/development/snowplow/index.md b/doc/development/snowplow/index.md index 4a61beaf626..9b0f5178559 100644 --- a/doc/development/snowplow/index.md +++ b/doc/development/snowplow/index.md @@ -609,7 +609,7 @@ Snowplow Micro is a Docker-based solution for testing frontend and backend event 1. Restart GDK: ```shell - `gdk restart` + gdk restart ``` 1. Send a test Snowplow event from the Rails console: diff --git a/lib/sidebars/projects/menus/settings_menu.rb b/lib/sidebars/projects/menus/settings_menu.rb index ac41e534842..6439c97d0bc 100644 --- a/lib/sidebars/projects/menus/settings_menu.rb +++ b/lib/sidebars/projects/menus/settings_menu.rb @@ -17,6 +17,7 @@ module Sidebars add_item(monitor_menu_item) add_item(pages_menu_item) add_item(packages_and_registries_menu_item) + add_item(usage_quotas_menu_item) true end @@ -141,6 +142,19 @@ module Sidebars item_id: :packages_and_registries ) end + + def usage_quotas_menu_item + unless Feature.enabled?(:project_storage_ui, context.project&.group, default_enabled: :yaml) + return ::Sidebars::NilMenuItem.new(item_id: :usage_quotas) + end + + ::Sidebars::MenuItem.new( + title: s_('UsageQuota|Usage Quotas'), + link: project_usage_quotas_path(context.project), + active_routes: { path: 'usage_quotas#index' }, + item_id: :usage_quotas + ) + end end end end diff --git a/lib/system_check/incoming_email_check.rb b/lib/system_check/incoming_email_check.rb index e0e1147711c..84033ada710 100644 --- a/lib/system_check/incoming_email_check.rb +++ b/lib/system_check/incoming_email_check.rb @@ -7,9 +7,11 @@ module SystemCheck def multi_check if Gitlab.config.incoming_email.enabled - checks = [ - SystemCheck::IncomingEmail::ImapAuthenticationCheck - ] + checks = [] + + if Gitlab.config.incoming_email.inbox_method == 'imap' + checks << SystemCheck::IncomingEmail::ImapAuthenticationCheck + end if Rails.env.production? checks << SystemCheck::IncomingEmail::InitdConfiguredCheck diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 934f51254db..fc1af4f678f 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -28892,6 +28892,9 @@ msgstr "" msgid "Runners|Runners" msgstr "" +msgid "Runners|Runners in this group: %{groupRunnersCount}" +msgstr "" + msgid "Runners|Shared runners are available to every project in a GitLab instance. If you want a runner to build only specific projects, restrict the project in the table below. After you restrict a runner to a project, you cannot change it back to a shared runner." msgstr "" @@ -36088,6 +36091,9 @@ msgstr "" msgid "UsageQuota|Usage of group resources across the projects in the %{strong_start}%{group_name}%{strong_end} group" msgstr "" +msgid "UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project" +msgstr "" + msgid "UsageQuota|Usage of resources across your projects" msgstr "" diff --git a/spec/controllers/groups/runners_controller_spec.rb b/spec/controllers/groups/runners_controller_spec.rb index 1808969cd60..a8830efe653 100644 --- a/spec/controllers/groups/runners_controller_spec.rb +++ b/spec/controllers/groups/runners_controller_spec.rb @@ -3,11 +3,13 @@ require 'spec_helper' RSpec.describe Groups::RunnersController do - let(:user) { create(:user) } - let(:group) { create(:group) } - let(:runner) { create(:ci_runner, :group, groups: [group]) } - let(:project) { create(:project, group: group) } - let(:runner_project) { create(:ci_runner, :project, projects: [project]) } + let_it_be(:user) { create(:user) } + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, group: group) } + + let!(:runner) { create(:ci_runner, :group, groups: [group]) } + let!(:runner_project) { create(:ci_runner, :project, projects: [project]) } + let(:params_runner_project) { { group_id: group, id: runner_project } } let(:params) { { group_id: group, id: runner } } @@ -26,6 +28,7 @@ RSpec.describe Groups::RunnersController do expect(response).to have_gitlab_http_status(:ok) expect(response).to render_template(:index) + expect(assigns(:group_runners_limited_count)).to be(2) end end diff --git a/spec/deprecation_toolkit_env.rb b/spec/deprecation_toolkit_env.rb index b95a8c599bf..f76cd5b396c 100644 --- a/spec/deprecation_toolkit_env.rb +++ b/spec/deprecation_toolkit_env.rb @@ -57,9 +57,7 @@ module DeprecationToolkitEnv # the dependency causing the problem. # See https://gitlab.com/gitlab-org/gitlab/-/commit/aea37f506bbe036378998916d374966c031bf347#note_647515736 def self.allowed_kwarg_warning_paths - %w[ - actionpack-6.1.3.2/lib/action_dispatch/routing/route_set.rb - ] + %w[] end def self.configure! diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb index e29a58f43b9..d5d6f534def 100644 --- a/spec/frontend/fixtures/runner.rb +++ b/spec/frontend/fixtures/runner.rb @@ -14,6 +14,7 @@ RSpec.describe 'Runner (JavaScript fixtures)' do let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') } let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') } + let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') } let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') } query_path = 'runner/graphql/' @@ -27,14 +28,14 @@ RSpec.describe 'Runner (JavaScript fixtures)' do remove_repository(project) end - before do - sign_in(admin) - enable_admin_mode!(admin) - end - describe GraphQL::Query, type: :request do get_runners_query_name = 'get_runners.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runners_query_name}") end @@ -55,6 +56,11 @@ RSpec.describe 'Runner (JavaScript fixtures)' do describe GraphQL::Query, type: :request do get_runner_query_name = 'get_runner.query.graphql' + before do + sign_in(admin) + enable_admin_mode!(admin) + end + let_it_be(:query) do get_graphql_query_as_string("#{query_path}#{get_runner_query_name}") end @@ -67,4 +73,35 @@ RSpec.describe 'Runner (JavaScript fixtures)' do expect_graphql_errors_to_be_empty end end + + describe GraphQL::Query, type: :request do + get_group_runners_query_name = 'get_group_runners.query.graphql' + + let_it_be(:group_owner) { create(:user) } + + before do + group.add_owner(group_owner) + end + + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}") + end + + it "#{fixtures_path}#{get_group_runners_query_name}.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path + }) + + expect_graphql_errors_to_be_empty + end + + it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do + post_graphql(query, current_user: group_owner, variables: { + groupFullPath: group.full_path, + first: 1 + }) + + expect_graphql_errors_to_be_empty + end + end end diff --git a/spec/frontend/projects/storage_counter/app_spec.js b/spec/frontend/projects/storage_counter/app_spec.js new file mode 100644 index 00000000000..cf71a782f21 --- /dev/null +++ b/spec/frontend/projects/storage_counter/app_spec.js @@ -0,0 +1,22 @@ +import { shallowMount } from '@vue/test-utils'; +import StorageCounterApp from '~/projects/storage_counter/components/app.vue'; + +describe('Storage counter app', () => { + let wrapper; + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(StorageCounterApp, { propsData }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders app successfully', () => { + expect(wrapper.text()).toBe('Usage'); + }); +}); diff --git a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js index c1596711be7..3292f635f6b 100644 --- a/spec/frontend/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/runner/admin_runners/admin_runners_app_spec.js @@ -2,6 +2,7 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createFlash from '~/flash'; import { updateHistory } from '~/lib/utils/url_utility'; @@ -14,16 +15,20 @@ import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; import { + ADMIN_FILTERED_SEARCH_NAMESPACE, CREATED_ASC, CREATED_DESC, DEFAULT_SORT, INSTANCE_TYPE, PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, STATUS_ACTIVE, RUNNER_PAGE_SIZE, } from '~/runner/constants'; import getRunnersQuery from '~/runner/graphql/get_runners.query.graphql'; import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { runnersData, runnersDataPaginated } from '../mock_data'; @@ -47,10 +52,14 @@ describe('AdminRunnersApp', () => { const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); const findRunnerList = () => wrapper.findComponent(RunnerList); - const findRunnerPagination = () => wrapper.findComponent(RunnerPagination); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); - const createComponentWithApollo = ({ props = {}, mountFn = shallowMount } = {}) => { + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { const handlers = [[getRunnersQuery, mockRunnersQuery]]; wrapper = mountFn(AdminRunnersApp, { @@ -68,7 +77,7 @@ describe('AdminRunnersApp', () => { setWindowLocation('/admin/runners'); mockRunnersQuery = jest.fn().mockResolvedValue(runnersData); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -77,8 +86,16 @@ describe('AdminRunnersApp', () => { wrapper.destroy(); }); + it('shows the runner type help', () => { + expect(findRunnerTypeHelp().exists()).toBe(true); + }); + + it('shows the runner setup instructions', () => { + expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + }); + it('shows the runners list', () => { - expect(runnersData.data.runners.nodes).toMatchObject(findRunnerList().props('runners')); + expect(findRunnerList().props('runners')).toEqual(runnersData.data.runners.nodes); }); it('requests the runners with no filters', () => { @@ -90,20 +107,38 @@ describe('AdminRunnersApp', () => { }); }); - it('shows the runner type help', () => { - expect(findRunnerTypeHelp().exists()).toBe(true); + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_TAG, + recentTokenValuesStorageKey: `${ADMIN_FILTERED_SEARCH_NAMESPACE}-recent-tags`, + }), + ]); }); - it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); - expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); + it('shows the active runner count', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners currently online: ${mockActiveRunnersCount}`, + ); }); describe('when a filter is preselected', () => { beforeEach(async () => { setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`); - createComponentWithApollo(); + createComponent(); await waitForPromises(); }); @@ -133,7 +168,7 @@ describe('AdminRunnersApp', () => { describe('when a filter is selected by the user', () => { beforeEach(() => { findRunnerFilteredSearchBar().vm.$emit('input', { - filters: [{ type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }], + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], sort: CREATED_ASC, }); }); @@ -154,11 +189,19 @@ describe('AdminRunnersApp', () => { }); }); + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + describe('when no runners are found', () => { beforeEach(async () => { - mockRunnersQuery = jest.fn().mockResolvedValue({ data: { runners: { nodes: [] } } }); - createComponentWithApollo(); - await waitForPromises(); + mockRunnersQuery = jest.fn().mockResolvedValue({ + data: { + runners: { nodes: [] }, + }, + }); + createComponent(); }); it('shows a message for no results', async () => { @@ -166,17 +209,14 @@ describe('AdminRunnersApp', () => { }); }); - it('when runners have not loaded, shows a loading state', () => { - createComponentWithApollo(); - expect(findRunnerList().props('loading')).toBe(true); - }); - describe('when runners query fails', () => { - beforeEach(async () => { + beforeEach(() => { mockRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); - createComponentWithApollo(); + createComponent(); + }); - await waitForPromises(); + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); }); it('error is reported to sentry', async () => { @@ -185,17 +225,13 @@ describe('AdminRunnersApp', () => { component: 'AdminRunnersApp', }); }); - - it('error is shown to the user', async () => { - expect(createFlash).toHaveBeenCalledTimes(1); - }); }); describe('Pagination', () => { beforeEach(() => { mockRunnersQuery = jest.fn().mockResolvedValue(runnersDataPaginated); - createComponentWithApollo({ mountFn: mount }); + createComponent({ mountFn: mount }); }); it('more pages can be selected', () => { @@ -203,14 +239,11 @@ describe('AdminRunnersApp', () => { }); it('cannot navigate to the previous page', () => { - expect(findRunnerPagination().find('[aria-disabled]').text()).toBe('Prev'); + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); }); it('navigates to the next page', async () => { - const nextPageBtn = findRunnerPagination().find('a'); - expect(nextPageBtn.text()).toBe('Next'); - - await nextPageBtn.trigger('click'); + await findRunnerPaginationNext().trigger('click'); expect(mockRunnersQuery).toHaveBeenLastCalledWith({ sort: CREATED_DESC, diff --git a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js index 85cf7ea92df..46948af1f28 100644 --- a/spec/frontend/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/runner/components/runner_filtered_search_bar_spec.js @@ -2,8 +2,16 @@ import { GlFilteredSearch, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import { statusTokenConfig } from '~/runner/components/search_tokens/status_token_config'; import TagToken from '~/runner/components/search_tokens/tag_token.vue'; -import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG } from '~/runner/constants'; +import { tagTokenConfig } from '~/runner/components/search_tokens/tag_token_config'; +import { typeTokenConfig } from '~/runner/components/search_tokens/type_token_config'; +import { + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + PARAM_KEY_TAG, + STATUS_ACTIVE, +} from '~/runner/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -13,12 +21,12 @@ describe('RunnerList', () => { const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); const findGlFilteredSearch = () => wrapper.findComponent(GlFilteredSearch); const findSortOptions = () => wrapper.findAllComponents(GlDropdownItem); - const findActiveRunnersMessage = () => wrapper.findByTestId('active-runners-message'); + const findActiveRunnersMessage = () => wrapper.findByTestId('runner-count'); const mockDefaultSort = 'CREATED_DESC'; const mockOtherSort = 'CONTACTED_DESC'; const mockFilters = [ - { type: PARAM_KEY_STATUS, value: { data: 'ACTIVE', operator: '=' } }, + { type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }, { type: 'filtered-search-term', value: { data: '' } }, ]; const mockActiveRunnersCount = 2; @@ -28,13 +36,16 @@ describe('RunnerList', () => { shallowMount(RunnerFilteredSearchBar, { propsData: { namespace: 'runners', + tokens: [], value: { filters: [], sort: mockDefaultSort, }, - activeRunnersCount: mockActiveRunnersCount, ...props, }, + slots: { + 'runner-count': `Runners currently online: ${mockActiveRunnersCount}`, + }, stubs: { FilteredSearch, GlFilteredSearch, @@ -64,12 +75,6 @@ describe('RunnerList', () => { ); }); - it('Displays a large active runner count', () => { - createComponent({ props: { activeRunnersCount: 2000 } }); - - expect(findActiveRunnersMessage().text()).toBe('Runners currently online: 2,000'); - }); - it('sets sorting options', () => { const SORT_OPTIONS_COUNT = 2; @@ -78,7 +83,13 @@ describe('RunnerList', () => { expect(findSortOptions().at(1).text()).toBe('Last contact'); }); - it('sets tokens', () => { + it('sets tokens to the filtered search', () => { + createComponent({ + props: { + tokens: [statusTokenConfig, typeTokenConfig, tagTokenConfig], + }, + }); + expect(findFilteredSearch().props('tokens')).toEqual([ expect.objectContaining({ type: PARAM_KEY_STATUS, diff --git a/spec/frontend/runner/components/runner_list_spec.js b/spec/frontend/runner/components/runner_list_spec.js index 5fff3581e39..344d1e5c150 100644 --- a/spec/frontend/runner/components/runner_list_spec.js +++ b/spec/frontend/runner/components/runner_list_spec.js @@ -56,7 +56,7 @@ describe('RunnerList', () => { }); it('Displays a list of runners', () => { - expect(findRows()).toHaveLength(3); + expect(findRows()).toHaveLength(4); expect(findSkeletonLoader().exists()).toBe(false); }); diff --git a/spec/frontend/runner/group_runners/group_runners_app_spec.js b/spec/frontend/runner/group_runners/group_runners_app_spec.js index 6a0863e92b4..e80da40e3bd 100644 --- a/spec/frontend/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/runner/group_runners/group_runners_app_spec.js @@ -1,26 +1,85 @@ -import { shallowMount } from '@vue/test-utils'; +import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import { updateHistory } from '~/lib/utils/url_utility'; + +import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue'; +import RunnerList from '~/runner/components/runner_list.vue'; import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue'; +import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerTypeHelp from '~/runner/components/runner_type_help.vue'; + +import { + CREATED_ASC, + CREATED_DESC, + DEFAULT_SORT, + INSTANCE_TYPE, + PARAM_KEY_STATUS, + PARAM_KEY_RUNNER_TYPE, + STATUS_ACTIVE, + RUNNER_PAGE_SIZE, +} from '~/runner/constants'; +import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue'; +import { captureException } from '~/runner/sentry_utils'; +import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); +const mockGroupFullPath = 'group1'; const mockRegistrationToken = 'AABBCC'; +const mockRunners = groupRunnersData.data.group.runners.nodes; +const mockGroupRunnersLimitedCount = mockRunners.length; + +jest.mock('~/flash'); +jest.mock('~/runner/sentry_utils'); +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + updateHistory: jest.fn(), +})); describe('GroupRunnersApp', () => { let wrapper; + let mockGroupRunnersQuery; const findRunnerTypeHelp = () => wrapper.findComponent(RunnerTypeHelp); const findRunnerManualSetupHelp = () => wrapper.findComponent(RunnerManualSetupHelp); + const findRunnerList = () => wrapper.findComponent(RunnerList); + const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); + const findRunnerPaginationPrev = () => + findRunnerPagination().findByLabelText('Go to previous page'); + const findRunnerPaginationNext = () => findRunnerPagination().findByLabelText('Go to next page'); + const findRunnerFilteredSearchBar = () => wrapper.findComponent(RunnerFilteredSearchBar); + const findFilteredSearch = () => wrapper.findComponent(FilteredSearch); + + const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]]; - const createComponent = ({ mountFn = shallowMount } = {}) => { wrapper = mountFn(GroupRunnersApp, { + localVue, + apolloProvider: createMockApollo(handlers), propsData: { registrationToken: mockRegistrationToken, + groupFullPath: mockGroupFullPath, + groupRunnersLimitedCount: mockGroupRunnersLimitedCount, + ...props, }, }); }; - beforeEach(() => { + beforeEach(async () => { + setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`); + + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData); + createComponent(); + await waitForPromises(); }); it('shows the runner type help', () => { @@ -28,7 +87,179 @@ describe('GroupRunnersApp', () => { }); it('shows the runner setup instructions', () => { - expect(findRunnerManualSetupHelp().exists()).toBe(true); expect(findRunnerManualSetupHelp().props('registrationToken')).toBe(mockRegistrationToken); }); + + it('shows the runners list', () => { + expect(findRunnerList().props('runners')).toEqual(groupRunnersData.data.group.runners.nodes); + }); + + it('requests the runners with group path and no other filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: undefined, + type: undefined, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + + it('sets tokens in the filtered search', () => { + createComponent({ mountFn: mount }); + + expect(findFilteredSearch().props('tokens')).toEqual([ + expect.objectContaining({ + type: PARAM_KEY_STATUS, + options: expect.any(Array), + }), + expect.objectContaining({ + type: PARAM_KEY_RUNNER_TYPE, + options: expect.any(Array), + }), + ]); + }); + + describe('shows the active runner count', () => { + it('with a regular value', () => { + createComponent({ mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch( + `Runners in this group: ${mockGroupRunnersLimitedCount}`, + ); + }); + + it('at the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000`); + }); + + it('over the limit', () => { + createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount }); + + expect(findRunnerFilteredSearchBar().text()).toMatch(`Runners in this group: 1,000+`); + }); + }); + + describe('when a filter is preselected', () => { + beforeEach(async () => { + setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); + + createComponent(); + await waitForPromises(); + }); + + it('sets the filters in the search bar', () => { + expect(findRunnerFilteredSearchBar().props('value')).toEqual({ + filters: [ + { type: 'status', value: { data: STATUS_ACTIVE, operator: '=' } }, + { type: 'runner_type', value: { data: INSTANCE_TYPE, operator: '=' } }, + ], + sort: 'CREATED_DESC', + pagination: { page: 1 }, + }); + }); + + it('requests the runners with filter parameters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + type: INSTANCE_TYPE, + sort: DEFAULT_SORT, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + describe('when a filter is selected by the user', () => { + beforeEach(() => { + findRunnerFilteredSearchBar().vm.$emit('input', { + filters: [{ type: PARAM_KEY_STATUS, value: { data: STATUS_ACTIVE, operator: '=' } }], + sort: CREATED_ASC, + }); + }); + + it('updates the browser url', () => { + expect(updateHistory).toHaveBeenLastCalledWith({ + title: expect.any(String), + url: 'http://test.host/groups/group1/-/runners?status[]=ACTIVE&sort=CREATED_ASC', + }); + }); + + it('requests the runners with filters', () => { + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + status: STATUS_ACTIVE, + sort: CREATED_ASC, + first: RUNNER_PAGE_SIZE, + }); + }); + }); + + it('when runners have not loaded, shows a loading state', () => { + createComponent(); + expect(findRunnerList().props('loading')).toBe(true); + }); + + describe('when no runners are found', () => { + beforeEach(async () => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue({ + data: { + group: { + runners: { nodes: [] }, + }, + }, + }); + createComponent(); + }); + + it('shows a message for no results', async () => { + expect(wrapper.text()).toContain('No runners found'); + }); + }); + + describe('when runners query fails', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockRejectedValue(new Error('Error!')); + createComponent(); + }); + + it('error is shown to the user', async () => { + expect(createFlash).toHaveBeenCalledTimes(1); + }); + + it('error is reported to sentry', async () => { + expect(captureException).toHaveBeenCalledWith({ + error: new Error('Network error: Error!'), + component: 'GroupRunnersApp', + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersDataPaginated); + + createComponent({ mountFn: mount }); + }); + + it('more pages can be selected', () => { + expect(findRunnerPagination().text()).toMatchInterpolatedText('Prev Next'); + }); + + it('cannot navigate to the previous page', () => { + expect(findRunnerPaginationPrev().attributes('aria-disabled')).toBe('true'); + }); + + it('navigates to the next page', async () => { + await findRunnerPaginationNext().trigger('click'); + + expect(mockGroupRunnersQuery).toHaveBeenLastCalledWith({ + groupFullPath: mockGroupFullPath, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: groupRunnersDataPaginated.data.group.runners.pageInfo.endCursor, + }); + }); + }); }); diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js index 8f551feca6e..c90b9a4c426 100644 --- a/spec/frontend/runner/mock_data.js +++ b/spec/frontend/runner/mock_data.js @@ -1,6 +1,14 @@ +const runnerFixture = (filename) => getJSONFixture(`graphql/runner/${filename}`); + // Fixtures generated by: spec/frontend/fixtures/runner.rb -export const runnersData = getJSONFixture('graphql/runner/get_runners.query.graphql.json'); -export const runnersDataPaginated = getJSONFixture( - 'graphql/runner/get_runners.query.graphql.paginated.json', + +// Admin queries +export const runnersData = runnerFixture('get_runners.query.graphql.json'); +export const runnersDataPaginated = runnerFixture('get_runners.query.graphql.paginated.json'); +export const runnerData = runnerFixture('get_runner.query.graphql.json'); + +// Group queries +export const groupRunnersData = runnerFixture('get_group_runners.query.graphql.json'); +export const groupRunnersDataPaginated = runnerFixture( + 'get_group_runners.query.graphql.paginated.json', ); -export const runnerData = getJSONFixture('graphql/runner/get_runner.query.graphql.json'); diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb index 9b79614db20..3079c781d73 100644 --- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb @@ -158,5 +158,31 @@ RSpec.describe Sidebars::Projects::Menus::SettingsMenu do end end end + + describe 'Usage Quotas' do + let(:item_id) { :usage_quotas } + + describe 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + specify { is_expected.not_to be_nil } + + describe 'when the user does not have access' do + let(:user) { nil } + + specify { is_expected.to be_nil } + end + end + + describe 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + specify { is_expected.to be_nil } + end + end end end diff --git a/spec/lib/system_check/incoming_email_check_spec.rb b/spec/lib/system_check/incoming_email_check_spec.rb new file mode 100644 index 00000000000..710702b93fc --- /dev/null +++ b/spec/lib/system_check/incoming_email_check_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe SystemCheck::IncomingEmailCheck do + before do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) + end + + describe '#multi_check' do + context 'when incoming e-mail is disabled' do + before do + stub_incoming_email_setting(enabled: false) + end + + it 'does not run any checks' do + expect(SystemCheck).not_to receive(:run) + + subject.multi_check + end + end + + context 'when incoming e-mail is enabled for IMAP' do + before do + stub_incoming_email_setting(enabled: true) + end + + it 'runs IMAP and mailroom checks' do + expect(SystemCheck).to receive(:run).with('Reply by email', [ + SystemCheck::IncomingEmail::ImapAuthenticationCheck, + SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomRunningCheck + ]) + + subject.multi_check + end + end + + context 'when incoming e-mail is enabled for Microsoft Graph' do + before do + stub_incoming_email_setting(enabled: true, inbox_method: 'microsoft_graph') + end + + it 'runs mailroom checks' do + expect(SystemCheck).to receive(:run).with('Reply by email', [ + SystemCheck::IncomingEmail::InitdConfiguredCheck, + SystemCheck::IncomingEmail::MailRoomRunningCheck + ]) + + subject.multi_check + end + end + end +end diff --git a/spec/requests/projects/usage_quotas_spec.rb b/spec/requests/projects/usage_quotas_spec.rb new file mode 100644 index 00000000000..4586d3bec4f --- /dev/null +++ b/spec/requests/projects/usage_quotas_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project Usage Quotas' do + let_it_be(:project) { create(:project) } + let_it_be(:role) { :maintainer } + let_it_be(:user) { create(:user) } + + before do + project.add_role(user, role) + login_as(user) + end + + shared_examples 'response with 404 status' do + it 'renders :not_found' do + get project_usage_quotas_path(project) + + expect(response).to have_gitlab_http_status(:not_found) + expect(response.body).not_to include(project_usage_quotas_path(project)) + end + end + + describe 'GET /:namespace/:project/usage_quotas' do + context 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + it 'renders usage quotas path' do + get project_usage_quotas_path(project) + + expect(response).to have_gitlab_http_status(:ok) + expect(response.body).to include(project_usage_quotas_path(project)) + expect(response.body).to include("Usage of project resources across the <strong>#{project.name}</strong> project") + end + + context 'renders :not_found for user without permission' do + let(:role) { :developer } + + it_behaves_like 'response with 404 status' + end + end + + context 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + it_behaves_like 'response with 404 status' + end + end +end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 8ae0885056e..2abc52fce85 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -118,7 +118,8 @@ RSpec.shared_context 'project navbar structure' do _('Access Tokens'), _('Repository'), _('CI/CD'), - _('Monitor') + _('Monitor'), + (s_('UsageQuota|Usage Quotas') if Feature.enabled?(:project_storage_ui, default_enabled: :yaml)) ] } ].compact diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index 3afebfbedab..9a31d599808 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -968,6 +968,32 @@ RSpec.describe 'layouts/nav/sidebar/_project' do end end end + + describe 'Usage Quotas' do + context 'with project_storage_ui feature flag enabled' do + before do + stub_feature_flags(project_storage_ui: true) + end + + it 'has a link to Usage Quotas' do + render + + expect(rendered).to have_link('Usage Quotas', href: project_usage_quotas_path(project)) + end + end + + context 'with project_storage_ui feature flag disabled' do + before do + stub_feature_flags(project_storage_ui: false) + end + + it 'does not have a link to Usage Quotas' do + render + + expect(rendered).not_to have_link('Usage Quotas', href: project_usage_quotas_path(project)) + end + end + end end describe 'Hidden menus' do |