From f2f5257093f88ca77d227adf38afe45c9345ccf2 Mon Sep 17 00:00:00 2001 From: Sheldon Led Date: Tue, 26 Apr 2022 14:48:30 +0100 Subject: [PATCH 1/6] PipelineUsageApp: Add namespace usage overview --- .../usage_quotas/pipelines/components/app.vue | 95 +++++++++++++- .../pipelines/components/usage_overview.vue | 57 +++++++++ .../usage_quotas/pipelines/constants.js | 12 ++ .../usage_quotas/pipelines/index.js | 16 +++ ee/app/helpers/ee/namespaces_helper.rb | 13 +- .../pipelines/components/app_spec.js | 118 +++++++++++++++++- .../components/usage_overview_spec.js | 65 ++++++++++ .../usage_quotas/pipelines/mock_data.js | 28 +++++ ee/spec/helpers/ee/namespaces_helper_spec.rb | 15 ++- locale/gitlab.pot | 9 ++ 10 files changed, 419 insertions(+), 9 deletions(-) create mode 100644 ee/app/assets/javascripts/usage_quotas/pipelines/components/usage_overview.vue create mode 100644 ee/spec/frontend/usage_quotas/pipelines/components/usage_overview_spec.js diff --git a/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue b/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue index a8d19f07256521..d158deb2b68ea2 100644 --- a/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue +++ b/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue @@ -1,5 +1,6 @@ @@ -123,6 +190,26 @@ export default { {{ error }}
+
+ + +
+import { GlIcon, GlLink, GlProgressBar } from '@gitlab/ui'; + +export default { + name: 'PipelinesUsageOverview', + components: { GlIcon, GlLink, GlProgressBar }, + inject: ['ciMinutesDisplayMinutesAvailableData'], + props: { + minutesTitle: { + type: String, + required: true, + }, + minutesUsed: { + type: String, + required: true, + }, + minutesUsedPercentage: { + type: String, + required: true, + }, + minutesLimit: { + type: String, + required: true, + }, + helpLinkHref: { + type: String, + required: true, + }, + helpLinkLabel: { + type: String, + required: true, + }, + }, +}; + + + diff --git a/ee/app/assets/javascripts/usage_quotas/pipelines/constants.js b/ee/app/assets/javascripts/usage_quotas/pipelines/constants.js index 481c067200ee17..11a9d8ae744d4a 100644 --- a/ee/app/assets/javascripts/usage_quotas/pipelines/constants.js +++ b/ee/app/assets/javascripts/usage_quotas/pipelines/constants.js @@ -16,6 +16,13 @@ export const PROJECTS_TABLE_FIELDS = [ }, ]; +export const TITLE_USAGE_SINCE = s__('UsageQuota|Usage since %{usageSince}'); +export const TITLE_CURRENT_PERIOD = s__('UsageQuota|Current period usage'); +export const TOTAL_USED_UNLIMITED = __('Unlimited'); +export const MINUTES_USED = __('%{minutesUsed} minutes'); +export const ADDITIONAL_MINUTES = __('Additional minutes'); +export const PERCENTAGE_USED = __('%{percentageUsed}%% used'); + export const ERROR_MESSAGE = s__( 'UsageQuota|Something went wrong while fetching pipeline statistics', ); @@ -28,3 +35,8 @@ export const LABEL_NO_PROJECTS = s__( 'UsageQuota|This namespace has no projects which use shared runners', ); export const USAGE_QUOTAS_HELP_LINK = helpPagePath('user/usage_quotas'); +export const ADDITIONAL_MINUTES_HELP_LINK = helpPagePath('ci/pipelines/cicd_minutes', { + anchor: 'purchase-additional-cicd-minutes-free-saas', +}); +export const CI_MINUTES_HELP_LINK = helpPagePath('ci/pipelines/cicd_minutes'); +export const CI_MINUTES_HELP_LINK_LABEL = __('Shared runners help link'); diff --git a/ee/app/assets/javascripts/usage_quotas/pipelines/index.js b/ee/app/assets/javascripts/usage_quotas/pipelines/index.js index 36333375dccb29..ff378948c6b657 100644 --- a/ee/app/assets/javascripts/usage_quotas/pipelines/index.js +++ b/ee/app/assets/javascripts/usage_quotas/pipelines/index.js @@ -20,6 +20,14 @@ export default () => { namespaceActualPlanName, userNamespace, ciMinutesAnyProjectEnabled, + ciMinutesDisplayMinutesAvailableData, + ciMinutesLastResetDate, + ciMinutesMonthlyMinutesLimit, + ciMinutesMonthlyMinutesUsed, + ciMinutesMonthlyMinutesUsedPercentage, + ciMinutesPurchasedMinutesLimit, + ciMinutesPurchasedMinutesUsed, + ciMinutesPurchasedMinutesUsedPercentage, buyAdditionalMinutesPath, buyAdditionalMinutesTarget, } = el.dataset; @@ -38,6 +46,14 @@ export default () => { namespaceActualPlanName, userNamespace: parseBoolean(userNamespace), ciMinutesAnyProjectEnabled: parseBoolean(ciMinutesAnyProjectEnabled), + ciMinutesDisplayMinutesAvailableData: parseBoolean(ciMinutesDisplayMinutesAvailableData), + ciMinutesLastResetDate, + ciMinutesMonthlyMinutesLimit, + ciMinutesMonthlyMinutesUsed, + ciMinutesMonthlyMinutesUsedPercentage, + ciMinutesPurchasedMinutesLimit, + ciMinutesPurchasedMinutesUsed, + ciMinutesPurchasedMinutesUsedPercentage, buyAdditionalMinutesPath, buyAdditionalMinutesTarget, }, diff --git a/ee/app/helpers/ee/namespaces_helper.rb b/ee/app/helpers/ee/namespaces_helper.rb index 3760a75c431e5e..aa1afbef421be5 100644 --- a/ee/app/helpers/ee/namespaces_helper.rb +++ b/ee/app/helpers/ee/namespaces_helper.rb @@ -71,11 +71,20 @@ def show_minute_limit_banner?(namespace) def pipeline_usage_app_data(namespace) return super unless ::Gitlab::CurrentSettings.should_check_namespace_plan? - minutes_usage_presenter = ::Ci::Minutes::UsagePresenter.new(namespace.ci_minutes_usage) + minutes_quota = namespace.ci_minutes_usage + minutes_usage_presenter = ::Ci::Minutes::UsagePresenter.new(minutes_quota) super.merge( ci_minutes: { - any_project_enabled: minutes_usage_presenter.any_project_enabled?.to_s + any_project_enabled: minutes_usage_presenter.any_project_enabled?.to_s, + last_reset_date: minutes_quota.reset_date.present? ? minutes_quota.reset_date.strftime('%b %d, %Y') : '', + display_minutes_available_data: minutes_usage_presenter.display_minutes_available_data?.to_s, + monthly_minutes_used: minutes_usage_presenter.monthly_minutes_report.used, + monthly_minutes_used_percentage: minutes_usage_presenter.monthly_percent_used, + monthly_minutes_limit: minutes_usage_presenter.monthly_minutes_report.limit, + purchased_minutes_used: minutes_usage_presenter.purchased_minutes_report.used, + purchased_minutes_used_percentage: minutes_usage_presenter.purchased_percent_used, + purchased_minutes_limit: minutes_usage_presenter.purchased_minutes_report.limit }, buy_additional_minutes_path: buy_additional_minutes_path(namespace), buy_additional_minutes_target: buy_addon_target_attr(namespace) diff --git a/ee/spec/frontend/usage_quotas/pipelines/components/app_spec.js b/ee/spec/frontend/usage_quotas/pipelines/components/app_spec.js index dcf1a8725d4ddc..7b56bc58e88c37 100644 --- a/ee/spec/frontend/usage_quotas/pipelines/components/app_spec.js +++ b/ee/spec/frontend/usage_quotas/pipelines/components/app_spec.js @@ -2,13 +2,27 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { GlAlert, GlButton, GlLoadingIcon } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; +import { sprintf } from '~/locale'; import { pushEECproductAddToCartEvent } from '~/google_tag_manager'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import PipelineUsageApp from 'ee/usage_quotas/pipelines/components/app.vue'; import ProjectList from 'ee/usage_quotas/pipelines/components/project_list.vue'; -import { LABEL_BUY_ADDITIONAL_MINUTES, ERROR_MESSAGE } from 'ee/usage_quotas/pipelines/constants'; +import UsageOverview from 'ee/usage_quotas/pipelines/components/usage_overview.vue'; +import { + LABEL_BUY_ADDITIONAL_MINUTES, + ERROR_MESSAGE, + TITLE_USAGE_SINCE, + TITLE_CURRENT_PERIOD, + TOTAL_USED_UNLIMITED, + MINUTES_USED, + ADDITIONAL_MINUTES, + PERCENTAGE_USED, + ADDITIONAL_MINUTES_HELP_LINK, + CI_MINUTES_HELP_LINK, + CI_MINUTES_HELP_LINK_LABEL, +} from 'ee/usage_quotas/pipelines/constants'; import getNamespaceProjectsInfo from 'ee/usage_quotas/pipelines/queries/namespace_projects_info.query.graphql'; import getCiMinutesUsageNamespace from 'ee/usage_quotas/ci_minutes_usage/graphql/queries/ci_minutes_namespace.query.graphql'; import { @@ -47,6 +61,7 @@ describe('PipelineUsageApp', () => { const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findProjectList = () => wrapper.findComponent(ProjectList); const findBuyAdditionalMinutesButton = () => wrapper.findComponent(GlButton); + const findUsageOverviewComponents = () => wrapper.findAllComponents(UsageOverview); const createComponent = ({ provide = {}, mockApollo } = {}) => { wrapper = shallowMountExtended(PipelineUsageApp, { @@ -99,6 +114,107 @@ describe('PipelineUsageApp', () => { }); }); + describe('namespace ci usage overview', () => { + const mockApollo = createMockApolloProvider(); + + it('passes reset date for monthlyUsageTitle to minutes UsageOverview if present', async () => { + createComponent({ mockApollo }); + + await waitForPromises(); + + expect(findUsageOverviewComponents().at(0).props('minutesTitle')).toBe( + sprintf(TITLE_USAGE_SINCE, { + usageSince: defaultProvide.ciMinutesLastResetDate, + }), + ); + }); + + it('passes current period for monthlyUsageTitle to minutes UsageOverview if no reset date', async () => { + createComponent({ mockApollo, provide: { ciMinutesLastResetDate: '' } }); + + await waitForPromises(); + + expect(findUsageOverviewComponents().at(0).props('minutesTitle')).toBe(TITLE_CURRENT_PERIOD); + }); + + it('passes correct props to minutes UsageOverview', async () => { + createComponent({ mockApollo }); + + await waitForPromises(); + + expect(findUsageOverviewComponents().at(0).props()).toMatchObject({ + helpLinkHref: CI_MINUTES_HELP_LINK, + helpLinkLabel: CI_MINUTES_HELP_LINK_LABEL, + minutesLimit: defaultProvide.ciMinutesMonthlyMinutesLimit, + minutesTitle: sprintf(TITLE_USAGE_SINCE, { + usageSince: defaultProvide.ciMinutesLastResetDate, + }), + minutesUsed: sprintf(MINUTES_USED, { + minutesUsed: `${defaultProvide.ciMinutesMonthlyMinutesUsed} / ${defaultProvide.ciMinutesMonthlyMinutesLimit}`, + }), + minutesUsedPercentage: sprintf(PERCENTAGE_USED, { + percentageUsed: defaultProvide.ciMinutesMonthlyMinutesUsedPercentage, + }).replace(/%+/g, '%'), + }); + }); + + it('passes correct props to purchased minutes UsageOverview', async () => { + createComponent({ mockApollo }); + + await waitForPromises(); + + expect(findUsageOverviewComponents().at(1).props()).toMatchObject({ + helpLinkHref: ADDITIONAL_MINUTES_HELP_LINK, + helpLinkLabel: ADDITIONAL_MINUTES, + minutesLimit: defaultProvide.ciMinutesMonthlyMinutesLimit, + minutesTitle: ADDITIONAL_MINUTES, + minutesUsed: sprintf(MINUTES_USED, { + minutesUsed: `${defaultProvide.ciMinutesPurchasedMinutesUsed} / ${defaultProvide.ciMinutesPurchasedMinutesLimit}`, + }), + minutesUsedPercentage: sprintf(PERCENTAGE_USED, { + percentageUsed: defaultProvide.ciMinutesPurchasedMinutesUsedPercentage, + }).replace(/%+/g, '%'), + }); + }); + + it('shows unlimited as usagePercentage on minutes UsageOverview under correct circunstances', async () => { + createComponent({ + mockApollo, + provide: { + ciMinutesDisplayMinutesAvailableData: false, + ciMinutesAnyProjectEnabled: false, + }, + }); + + await waitForPromises(); + + expect(findUsageOverviewComponents().at(0).props('minutesUsedPercentage')).toBe( + TOTAL_USED_UNLIMITED, + ); + }); + + it.each` + displayData | purchasedLimit | showAdditionalMinutes + ${true} | ${'100'} | ${true} + ${true} | ${'0'} | ${false} + ${false} | ${'100'} | ${false} + ${false} | ${'0'} | ${false} + `( + 'shows additional minutes: $showAdditionalMinutes when displayData is $displayData and purchase limit is $purchasedLimit', + ({ displayData, purchasedLimit, showAdditionalMinutes }) => { + createComponent({ + mockApollo, + provide: { + ciMinutesDisplayMinutesAvailableData: displayData, + ciMinutesPurchasedMinutesLimit: purchasedLimit, + }, + }); + const expectedUsageOverviewInstances = showAdditionalMinutes ? 2 : 1; + expect(findUsageOverviewComponents().length).toBe(expectedUsageOverviewInstances); + }, + ); + }); + describe('with apollo fetching successful', () => { beforeEach(() => { const mockCiMinutesUsageQuery = { ...mockGetCiMinutesUsageNamespace }; diff --git a/ee/spec/frontend/usage_quotas/pipelines/components/usage_overview_spec.js b/ee/spec/frontend/usage_quotas/pipelines/components/usage_overview_spec.js new file mode 100644 index 00000000000000..e0d00d6f333417 --- /dev/null +++ b/ee/spec/frontend/usage_quotas/pipelines/components/usage_overview_spec.js @@ -0,0 +1,65 @@ +import { GlLink, GlProgressBar } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import UsageOverview from 'ee/usage_quotas/pipelines/components/usage_overview.vue'; +import { defaultProvide, defaultUsageOverviewProps } from '../mock_data'; + +describe('ProjectCIMinutesList', () => { + let wrapper; + + const createComponent = ({ provide = {}, props = {} } = {}) => { + wrapper = mountExtended(UsageOverview, { + propsData: { + ...defaultUsageOverviewProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + }); + }; + + const findMinutesTitle = () => wrapper.findByTestId('minutes_title'); + const findMinutesUsed = () => wrapper.find('[data-qa-selector="plan_ci_minutes"]'); + const findMinutesUsedPercentage = () => wrapper.findByTestId('minutes_used_percentage'); + const findHelpLink = () => wrapper.findComponent(GlLink); + const findGlProgressBar = () => wrapper.findComponent(GlProgressBar); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the minutes title properly', () => { + expect(findMinutesTitle().text()).toBe(defaultUsageOverviewProps.minutesTitle); + }); + + it('renders the minutes used properly', () => { + expect(findMinutesUsed().text()).toBe(defaultUsageOverviewProps.minutesUsed); + }); + + it('passes the correct data to the help link', () => { + expect(findHelpLink().attributes()).toMatchObject({ + 'aria-label': defaultUsageOverviewProps.helpLinkLabel, + class: 'gl-link', + href: defaultUsageOverviewProps.helpLinkHref, + }); + }); + + it('renders the minutes used percentage properly', () => { + expect(findMinutesUsedPercentage().text()).toBe( + defaultUsageOverviewProps.minutesUsedPercentage, + ); + }); + + it('passess the correct props to GlProgressBar', () => { + expect(findGlProgressBar().attributes()).toMatchObject({ + class: 'progress', + value: defaultUsageOverviewProps.minutesUsedPercentage, + variant: 'success', + }); + }); +}); diff --git a/ee/spec/frontend/usage_quotas/pipelines/mock_data.js b/ee/spec/frontend/usage_quotas/pipelines/mock_data.js index c57671617531d2..0b2817549eb924 100644 --- a/ee/spec/frontend/usage_quotas/pipelines/mock_data.js +++ b/ee/spec/frontend/usage_quotas/pipelines/mock_data.js @@ -1,6 +1,13 @@ import { formatDate } from '~/lib/utils/datetime_utility'; +import { sprintf } from '~/locale'; import { TEST_HOST } from 'helpers/test_constants'; import { getProjectMinutesUsage } from 'ee/usage_quotas/pipelines/utils'; +import { + TITLE_USAGE_SINCE, + MINUTES_USED, + CI_MINUTES_HELP_LINK, + CI_MINUTES_HELP_LINK_LABEL, +} from 'ee/usage_quotas/pipelines/constants'; export const defaultProvide = { namespacePath: 'mygroup', @@ -8,6 +15,14 @@ export const defaultProvide = { userNamespace: false, pageSize: '20', ciMinutesAnyProjectEnabled: true, + ciMinutesDisplayMinutesAvailableData: true, + ciMinutesLastResetDate: 'January 01, 2022', + ciMinutesMonthlyMinutesLimit: '100', + ciMinutesMonthlyMinutesUsed: '20', + ciMinutesMonthlyMinutesUsedPercentage: '20', + ciMinutesPurchasedMinutesLimit: '100', + ciMinutesPurchasedMinutesUsed: '20', + ciMinutesPurchasedMinutesUsedPercentage: '20', namespaceActualPlanName: 'MyGroup', buyAdditionalMinutesPath: `${TEST_HOST}/-/subscriptions/buy_minutes?selected_group=12345`, buyAdditionalMinutesTarget: '_self', @@ -82,3 +97,16 @@ export const defaultProjectListProps = { })), pageInfo: mockGetNamespaceProjectsInfo.data.namespace.projects.pageInfo, }; + +export const defaultUsageOverviewProps = { + helpLinkHref: CI_MINUTES_HELP_LINK, + helpLinkLabel: CI_MINUTES_HELP_LINK_LABEL, + minutesLimit: defaultProvide.ciMinutesMonthlyMinutesLimit, + minutesTitle: sprintf(TITLE_USAGE_SINCE, { + usageSince: defaultProvide.ciMinutesLastResetDate, + }), + minutesUsed: sprintf(MINUTES_USED, { + minutesUsed: `${defaultProvide.ciMinutesMonthlyMinutesUsed} / ${defaultProvide.ciMinutesMonthlyMinutesLimit}`, + }), + minutesUsedPercentage: defaultProvide.ciMinutesMonthlyMinutesUsedPercentage, +}; diff --git a/ee/spec/helpers/ee/namespaces_helper_spec.rb b/ee/spec/helpers/ee/namespaces_helper_spec.rb index a6ce581950a55c..6a4a2b04b49e9d 100644 --- a/ee/spec/helpers/ee/namespaces_helper_spec.rb +++ b/ee/spec/helpers/ee/namespaces_helper_spec.rb @@ -285,7 +285,8 @@ end it 'returns a hash with SaaS data' do - minutes_usage_presenter = ::Ci::Minutes::UsagePresenter.new(user_group.ci_minutes_usage) + minutes_quota = user_group.ci_minutes_usage + minutes_usage_presenter = ::Ci::Minutes::UsagePresenter.new(minutes_quota) expect(helper.pipeline_usage_app_data(user_group)).to eql({ namespace_actual_plan_name: user_group.actual_plan_name, @@ -293,7 +294,17 @@ namespace_id: user_group.id, user_namespace: user_group.user_namespace?.to_s, page_size: Kaminari.config.default_per_page, - ci_minutes: { any_project_enabled: minutes_usage_presenter.any_project_enabled?.to_s }, + ci_minutes: { + any_project_enabled: minutes_usage_presenter.any_project_enabled?.to_s, + last_reset_date: minutes_quota.reset_date.present? ? minutes_quota.reset_date.strftime('%b %d, %Y') : '', + display_minutes_available_data: minutes_usage_presenter.display_minutes_available_data?.to_s, + monthly_minutes_used: minutes_usage_presenter.monthly_minutes_report.used, + monthly_minutes_used_percentage: minutes_usage_presenter.monthly_percent_used, + monthly_minutes_limit: minutes_usage_presenter.monthly_minutes_report.limit, + purchased_minutes_used: minutes_usage_presenter.purchased_minutes_report.used, + purchased_minutes_used_percentage: minutes_usage_presenter.purchased_percent_used, + purchased_minutes_limit: minutes_usage_presenter.purchased_minutes_report.limit + }, buy_additional_minutes_path: EE::SUBSCRIPTIONS_MORE_MINUTES_URL, buy_additional_minutes_target: '_blank' }) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3b0006abae17db..3ba3bb661dadc4 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -790,6 +790,9 @@ msgstr "" msgid "%{milliseconds}ms" msgstr "" +msgid "%{minutesUsed} minutes" +msgstr "" + msgid "%{model_name} not found" msgstr "" @@ -858,6 +861,9 @@ msgstr "" msgid "%{openedIssues} open, %{closedIssues} closed" msgstr "" +msgid "%{percentageUsed}%% used" +msgstr "" + msgid "%{percentage}%% issues closed" msgstr "" @@ -40768,6 +40774,9 @@ msgstr "" msgid "UsageQuota|Usage quotas help link" msgstr "" +msgid "UsageQuota|Usage since %{usageSince}" +msgstr "" + msgid "UsageQuota|When you purchase additional storage, we automatically unlock projects that were locked when you reached the %{actualRepositorySizeLimit} limit." msgstr "" -- GitLab From a452d9b57a723ffc7b62410471af1cda04972901 Mon Sep 17 00:00:00 2001 From: Sheldon Led Date: Wed, 4 May 2022 16:36:29 +0000 Subject: [PATCH 2/6] Move CI minutes usage charts inside `PipelineUsageApp` --- .../usage_quotas/pipelines/components/app.vue | 9 ++++-- .../components/minutes_usage_charts.vue | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 ee/app/assets/javascripts/usage_quotas/pipelines/components/minutes_usage_charts.vue diff --git a/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue b/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue index d158deb2b68ea2..a52ae9f3c67960 100644 --- a/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue +++ b/ee/app/assets/javascripts/usage_quotas/pipelines/components/app.vue @@ -23,10 +23,11 @@ import { } from '../constants'; import ProjectList from './project_list.vue'; import UsageOverview from './usage_overview.vue'; +import MinutesUsageCharts from './minutes_usage_charts.vue'; export default { name: 'PipelineUsageApp', - components: { GlAlert, GlButton, GlLoadingIcon, ProjectList, UsageOverview }, + components: { GlAlert, GlButton, GlLoadingIcon, ProjectList, UsageOverview, MinutesUsageCharts }, inject: [ 'pageSize', 'namespacePath', @@ -49,7 +50,7 @@ export default { return { error: '', namespace: null, - ciMinutesUsages: null, + ciMinutesUsages: [], }; }, apollo: { @@ -169,6 +170,10 @@ export default {