Commit af4d71b5 authored by Ammar Alakkad's avatar Ammar Alakkad

Add project storage usage UI

parent b2fe1f89
...@@ -69,19 +69,20 @@ export function bytesToGiB(number) { ...@@ -69,19 +69,20 @@ export function bytesToGiB(number) {
* representation (e.g., giving it 1500 yields 1.5 KB). * representation (e.g., giving it 1500 yields 1.5 KB).
* *
* @param {Number} size * @param {Number} size
* @param {Number} digits - The number of digits to appear after the decimal point
* @returns {String} * @returns {String}
*/ */
export function numberToHumanSize(size) { export function numberToHumanSize(size, digits = 2) {
const abs = Math.abs(size); const abs = Math.abs(size);
if (abs < BYTES_IN_KIB) { if (abs < BYTES_IN_KIB) {
return sprintf(__('%{size} bytes'), { size }); return sprintf(__('%{size} bytes'), { size });
} else if (abs < BYTES_IN_KIB ** 2) { } else if (abs < BYTES_IN_KIB ** 2) {
return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(2) }); return sprintf(__('%{size} KiB'), { size: bytesToKiB(size).toFixed(digits) });
} else if (abs < BYTES_IN_KIB ** 3) { } else if (abs < BYTES_IN_KIB ** 3) {
return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(2) }); return sprintf(__('%{size} MiB'), { size: bytesToMiB(size).toFixed(digits) });
} }
return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(2) }); return sprintf(__('%{size} GiB'), { size: bytesToGiB(size).toFixed(digits) });
} }
/** /**
......
<script> <script>
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlLink, GlLoadingIcon } from '@gitlab/ui';
import { s__ } from '~/locale'; import { sprintf } from '~/locale';
import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
import {
ERROR_MESSAGE,
LEARN_MORE_LABEL,
USAGE_QUOTAS_LABEL,
TOTAL_USAGE_TITLE,
TOTAL_USAGE_SUBTITLE,
TOTAL_USAGE_DEFAULT_TEXT,
HELP_LINK_ARIA_LABEL,
} from '../constants';
import getProjectStorageCount from '../queries/project_storage.query.graphql'; import getProjectStorageCount from '../queries/project_storage.query.graphql';
import { parseGetProjectStorageResults } from '../utils'; import { parseGetProjectStorageResults } from '../utils';
import StorageTable from './storage_table.vue';
export default { export default {
name: 'StorageCounterApp', name: 'StorageCounterApp',
components: { components: {
GlAlert, GlAlert,
GlLink,
GlLoadingIcon,
StorageTable,
UsageGraph, UsageGraph,
}, },
inject: ['projectPath'], inject: ['projectPath', 'helpLinks'],
apollo: { apollo: {
project: { project: {
query: getProjectStorageCount, query: getProjectStorageCount,
...@@ -20,11 +33,11 @@ export default { ...@@ -20,11 +33,11 @@ export default {
fullPath: this.projectPath, fullPath: this.projectPath,
}; };
}, },
update: parseGetProjectStorageResults, update(data) {
return parseGetProjectStorageResults(data, this.helpLinks);
},
error() { error() {
this.error = s__( this.error = ERROR_MESSAGE;
'UsageQuota|Something went wrong while fetching project storage statistics',
);
}, },
}, },
}, },
...@@ -34,24 +47,60 @@ export default { ...@@ -34,24 +47,60 @@ export default {
error: '', error: '',
}; };
}, },
computed: {
totalUsage() {
return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT;
},
storageTypes() {
return this.project?.storage?.storageTypes || [];
},
},
methods: { methods: {
clearError() { clearError() {
this.error = ''; this.error = '';
}, },
helpLinkAriaLabel(linkTitle) {
return sprintf(HELP_LINK_ARIA_LABEL, {
linkTitle,
});
},
}, },
i18n: { LEARN_MORE_LABEL,
placeholder: s__('UsageQuota|Usage'), USAGE_QUOTAS_LABEL,
}, TOTAL_USAGE_TITLE,
TOTAL_USAGE_SUBTITLE,
}; };
</script> </script>
<template> <template>
<div> <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="md" />
<gl-alert v-if="error" variant="danger" @dismiss="clearError"> <gl-alert v-else-if="error" variant="danger" @dismiss="clearError">
{{ error }} {{ error }}
</gl-alert> </gl-alert>
<div v-else>{{ $options.i18n.placeholder }}</div> <div v-else>
<div class="gl-pt-5 gl-px-3">
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center">
<div>
<p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p>
<p class="gl-m-0 gl-text-gray-400">
{{ $options.TOTAL_USAGE_SUBTITLE }}
<gl-link
:href="helpLinks.usageQuotasHelpPagePath"
target="_blank"
:aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)"
data-testid="usage-quotas-help-link"
>
{{ $options.LEARN_MORE_LABEL }}
</gl-link>
</p>
</div>
<p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage">
{{ totalUsage }}
</p>
</div>
</div>
<div v-if="project.statistics" class="gl-w-full"> <div v-if="project.statistics" class="gl-w-full">
<usage-graph :root-storage-statistics="project.statistics" :limit="0" /> <usage-graph :root-storage-statistics="project.statistics" :limit="0" />
</div> </div>
<storage-table :storage-types="storageTypes" />
</div> </div>
</template> </template>
<script>
import { GlLink, GlIcon, GlTable, GlSprintf } from '@gitlab/ui';
import { thWidthClass } from '~/lib/utils/table_utility';
import { sprintf } from '~/locale';
import { PROJECT_TABLE_LABELS, HELP_LINK_ARIA_LABEL } from '../constants';
export default {
name: 'StorageTable',
components: {
GlLink,
GlIcon,
GlTable,
GlSprintf,
},
props: {
storageTypes: {
type: Array,
required: true,
},
},
methods: {
helpLinkAriaLabel(linkTitle) {
return sprintf(HELP_LINK_ARIA_LABEL, {
linkTitle,
});
},
},
projectTableFields: [
{
key: 'storageType',
label: PROJECT_TABLE_LABELS.STORAGE_TYPE,
thClass: thWidthClass(90),
sortable: true,
},
{
key: 'value',
label: PROJECT_TABLE_LABELS.VALUE,
thClass: thWidthClass(10),
sortable: true,
},
],
};
</script>
<template>
<gl-table :items="storageTypes" :fields="$options.projectTableFields">
<template #cell(storageType)="{ item }">
<p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`">
{{ item.storageType.name }}
<gl-link
v-if="item.storageType.helpPath"
:href="item.storageType.helpPath"
target="_blank"
:aria-label="helpLinkAriaLabel(item.storageType.name)"
:data-testid="`${item.storageType.id}-help-link`"
>
<gl-icon name="question" :size="12" />
</gl-link>
</p>
<p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`">
{{ item.storageType.description }}
</p>
<p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm">
<gl-icon name="warning" :size="12" />
<gl-sprintf :message="item.storageType.warningMessage">
<template #warningLink="{ content }">
<gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</p>
</template>
</gl-table>
</template>
import { s__, __ } from '~/locale';
export const PROJECT_STORAGE_TYPES = [
{
id: 'buildArtifactsSize',
name: s__('UsageQuota|Artifacts'),
description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'),
warningMessage: s__(
'UsageQuota|There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.',
),
warningLink: 'https://gitlab.com/groups/gitlab-org/-/epics/5380',
},
{
id: 'lfsObjectsSize',
name: s__('UsageQuota|LFS Storage'),
description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'),
},
{
id: 'packagesSize',
name: s__('UsageQuota|Packages'),
description: s__('UsageQuota|Code packages and container images.'),
},
{
id: 'repositorySize',
name: s__('UsageQuota|Repository'),
description: s__('UsageQuota|Git repository, managed by the Gitaly service.'),
},
{
id: 'snippetsSize',
name: s__('UsageQuota|Snippets'),
description: s__('UsageQuota|Shared bits of code and text.'),
},
{
id: 'uploadsSize',
name: s__('UsageQuota|Uploads'),
description: s__('UsageQuota|File attachments and smaller design graphics.'),
},
{
id: 'wikiSize',
name: s__('UsageQuota|Wiki'),
description: s__('UsageQuota|Wiki content.'),
},
];
export const PROJECT_TABLE_LABELS = {
STORAGE_TYPE: s__('UsageQuota|Storage type'),
VALUE: s__('UsageQuota|Usage'),
};
export const ERROR_MESSAGE = s__(
'UsageQuota|Something went wrong while fetching project storage statistics',
);
export const LEARN_MORE_LABEL = s__('Learn more.');
export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas');
export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link');
export const TOTAL_USAGE_DEFAULT_TEXT = __('N/A');
export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage Breakdown');
export const TOTAL_USAGE_SUBTITLE = s__(
'UsageQuota|Includes project registry, artifacts, packages, wiki, uploads and other items.',
);
...@@ -12,7 +12,17 @@ export default (containerId = 'js-project-storage-count-app') => { ...@@ -12,7 +12,17 @@ export default (containerId = 'js-project-storage-count-app') => {
return false; return false;
} }
const { projectPath } = el.dataset; const {
projectPath,
usageQuotasHelpPagePath,
buildArtifactsHelpPagePath,
lfsObjectsHelpPagePath,
packagesHelpPagePath,
repositoryHelpPagePath,
snippetsHelpPagePath,
uploadsHelpPagePath,
wikiHelpPagePath,
} = el.dataset;
const apolloProvider = new VueApollo({ const apolloProvider = new VueApollo({
defaultClient: createDefaultClient({}, { assumeImmutableResults: true }), defaultClient: createDefaultClient({}, { assumeImmutableResults: true }),
...@@ -23,6 +33,16 @@ export default (containerId = 'js-project-storage-count-app') => { ...@@ -23,6 +33,16 @@ export default (containerId = 'js-project-storage-count-app') => {
apolloProvider, apolloProvider,
provide: { provide: {
projectPath, projectPath,
helpLinks: {
usageQuotasHelpPagePath,
buildArtifactsHelpPagePath,
lfsObjectsHelpPagePath,
packagesHelpPagePath,
repositoryHelpPagePath,
snippetsHelpPagePath,
uploadsHelpPagePath,
wikiHelpPagePath,
},
}, },
render(createElement) { render(createElement) {
return createElement(StorageCounterApp); return createElement(StorageCounterApp);
......
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import { s__ } from '~/locale'; import { PROJECT_STORAGE_TYPES } from './constants';
const projectStorageTypes = [
{
id: 'buildArtifactsSize',
name: s__('UsageQuota|Artifacts'),
},
{
id: 'lfsObjectsSize',
name: s__('UsageQuota|LFS Storage'),
},
{
id: 'packagesSize',
name: s__('UsageQuota|Packages'),
},
{
id: 'repositorySize',
name: s__('UsageQuota|Repository'),
},
{
id: 'snippetsSize',
name: s__('UsageQuota|Snippets'),
},
{
id: 'uploadsSize',
name: s__('UsageQuota|Uploads'),
},
{
id: 'wikiSize',
name: s__('UsageQuota|Wiki'),
},
];
/** /**
* This method parses the results from `getProjectStorageCount` call. * This method parses the results from `getProjectStorageCount` call.
...@@ -38,26 +7,32 @@ const projectStorageTypes = [ ...@@ -38,26 +7,32 @@ const projectStorageTypes = [
* @param {Object} data graphql result * @param {Object} data graphql result
* @returns {Object} * @returns {Object}
*/ */
export const parseGetProjectStorageResults = (data) => { export const parseGetProjectStorageResults = (data, helpLinks) => {
const projectStatistics = data?.project?.statistics; const projectStatistics = data?.project?.statistics;
if (!projectStatistics) { if (!projectStatistics) {
return {}; return {};
} }
const { storageSize, ...storageStatistics } = projectStatistics; const { storageSize, ...storageStatistics } = projectStatistics;
const storageTypes = projectStorageTypes.reduce((types, currentType) => { const storageTypes = PROJECT_STORAGE_TYPES.reduce((types, currentType) => {
if (!storageStatistics[currentType.id]) { if (!storageStatistics[currentType.id]) {
return types; return types;
} }
const helpPathKey = currentType.id.replace(`Size`, `HelpPagePath`);
const helpPath = helpLinks[helpPathKey];
return types.concat({ return types.concat({
...currentType, storageType: {
value: numberToHumanSize(storageStatistics[currentType.id]), ...currentType,
helpPath,
},
value: numberToHumanSize(storageStatistics[currentType.id], 1),
}); });
}, []); }, []);
return { return {
storage: { storage: {
totalUsage: numberToHumanSize(storageSize), totalUsage: numberToHumanSize(storageSize, 1),
storageTypes, storageTypes,
}, },
statistics: projectStatistics, statistics: projectStatistics,
......
...@@ -8,6 +8,18 @@ class Projects::UsageQuotasController < Projects::ApplicationController ...@@ -8,6 +8,18 @@ class Projects::UsageQuotasController < Projects::ApplicationController
feature_category :utilization feature_category :utilization
def index
@storage_app_data = {
project_path: @project.full_path,
usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'),
packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'),
repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'),
snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'),
wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size')
}
end
private private
def verify_usage_quotas_enabled! def verify_usage_quotas_enabled!
......
...@@ -4,8 +4,10 @@ ...@@ -4,8 +4,10 @@
= s_('UsageQuota|Usage Quotas') = s_('UsageQuota|Usage Quotas')
.row .row
.col-sm-6 .col-sm-12
= 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 } = 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 } + '.'
%a{ href: help_page_path('user/usage_quotas.md') }
= s_('UsageQuota|Learn more about usage quotas') + '.'
.top-area.scrolling-tabs-container.inner-page-scroll-tabs .top-area.scrolling-tabs-container.inner-page-scroll-tabs
%ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-usage-quota-tabs{ role: 'tablist' } %ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-usage-quota-tabs{ role: 'tablist' }
...@@ -14,4 +16,4 @@ ...@@ -14,4 +16,4 @@
= s_('UsageQuota|Storage') = s_('UsageQuota|Storage')
.tab-content .tab-content
.tab-pane#storage-quota-tab .tab-pane#storage-quota-tab
#js-project-storage-count-app{ data: { project_path: @project.full_path } } #js-project-storage-count-app{ data: @storage_app_data }
...@@ -33,7 +33,7 @@ export default { ...@@ -33,7 +33,7 @@ export default {
'UsageQuota|This is the total amount of storage used across your projects within this namespace.', 'UsageQuota|This is the total amount of storage used across your projects within this namespace.',
), ),
link: { link: {
text: s__('UsageQuota|Learn more about usage quotas'), text: `${s__('UsageQuota|Learn more about usage quotas')}.`,
url: helpPagePath('user/usage_quotas'), url: helpPagePath('user/usage_quotas'),
}, },
}; };
......
...@@ -36144,6 +36144,9 @@ msgstr "" ...@@ -36144,6 +36144,9 @@ msgstr ""
msgid "UsageQuota|%{help_link_start}Shared runners%{help_link_end} are disabled, so there are no limits set on pipeline usage" msgid "UsageQuota|%{help_link_start}Shared runners%{help_link_end} are disabled, so there are no limits set on pipeline usage"
msgstr "" msgstr ""
msgid "UsageQuota|%{linkTitle} help link"
msgstr ""
msgid "UsageQuota|%{percentageLeft} of purchased storage is available" msgid "UsageQuota|%{percentageLeft} of purchased storage is available"
msgstr "" msgstr ""
...@@ -36153,6 +36156,9 @@ msgstr "" ...@@ -36153,6 +36156,9 @@ msgstr ""
msgid "UsageQuota|Artifacts is a sum of build and pipeline artifacts." msgid "UsageQuota|Artifacts is a sum of build and pipeline artifacts."
msgstr "" msgstr ""
msgid "UsageQuota|Audio samples, videos, datasets, and graphics."
msgstr ""
msgid "UsageQuota|Buy additional minutes" msgid "UsageQuota|Buy additional minutes"
msgstr "" msgstr ""
...@@ -36162,9 +36168,21 @@ msgstr "" ...@@ -36162,9 +36168,21 @@ msgstr ""
msgid "UsageQuota|CI minutes usage by project" msgid "UsageQuota|CI minutes usage by project"
msgstr "" msgstr ""
msgid "UsageQuota|Code packages and container images."
msgstr ""
msgid "UsageQuota|Current period usage" msgid "UsageQuota|Current period usage"
msgstr "" msgstr ""
msgid "UsageQuota|File attachments and smaller design graphics."
msgstr ""
msgid "UsageQuota|Git repository, managed by the Gitaly service."
msgstr ""
msgid "UsageQuota|Includes project registry, artifacts, packages, wiki, uploads and other items."
msgstr ""
msgid "UsageQuota|Increase storage temporarily" msgid "UsageQuota|Increase storage temporarily"
msgstr "" msgstr ""
...@@ -36183,6 +36201,9 @@ msgstr "" ...@@ -36183,6 +36201,9 @@ msgstr ""
msgid "UsageQuota|Packages" msgid "UsageQuota|Packages"
msgstr "" msgstr ""
msgid "UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD."
msgstr ""
msgid "UsageQuota|Pipelines" msgid "UsageQuota|Pipelines"
msgstr "" msgstr ""
...@@ -36201,6 +36222,9 @@ msgstr "" ...@@ -36201,6 +36222,9 @@ msgstr ""
msgid "UsageQuota|Seats" msgid "UsageQuota|Seats"
msgstr "" msgstr ""
msgid "UsageQuota|Shared bits of code and text."
msgstr ""
msgid "UsageQuota|Snippets" msgid "UsageQuota|Snippets"
msgstr "" msgstr ""
...@@ -36210,6 +36234,12 @@ msgstr "" ...@@ -36210,6 +36234,12 @@ msgstr ""
msgid "UsageQuota|Storage" msgid "UsageQuota|Storage"
msgstr "" msgstr ""
msgid "UsageQuota|Storage type"
msgstr ""
msgid "UsageQuota|There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}."
msgstr ""
msgid "UsageQuota|This is the total amount of storage used across your projects within this namespace." msgid "UsageQuota|This is the total amount of storage used across your projects within this namespace."
msgstr "" msgstr ""
...@@ -36249,6 +36279,9 @@ msgstr "" ...@@ -36249,6 +36279,9 @@ msgstr ""
msgid "UsageQuota|Usage" msgid "UsageQuota|Usage"
msgstr "" msgstr ""
msgid "UsageQuota|Usage Breakdown"
msgstr ""
msgid "UsageQuota|Usage Quotas" msgid "UsageQuota|Usage Quotas"
msgstr "" msgstr ""
...@@ -36273,6 +36306,9 @@ msgstr "" ...@@ -36273,6 +36306,9 @@ msgstr ""
msgid "UsageQuota|Wiki" msgid "UsageQuota|Wiki"
msgstr "" msgstr ""
msgid "UsageQuota|Wiki content."
msgstr ""
msgid "UsageQuota|Wikis" msgid "UsageQuota|Wikis"
msgstr "" msgstr ""
......
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import StorageCounterApp from '~/projects/storage_counter/components/app.vue'; import StorageCounterApp from '~/projects/storage_counter/components/app.vue';
import { TOTAL_USAGE_DEFAULT_TEXT } from '~/projects/storage_counter/constants';
import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql'; import getProjectStorageCount from '~/projects/storage_counter/queries/project_storage.query.graphql';
import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue'; import UsageGraph from '~/vue_shared/components/storage_counter/usage_graph.vue';
import { projectStorageCountResponse } from './mock_data'; import {
mockGetProjectStorageCountGraphQLResponse,
mockEmptyResponse,
projectData,
defaultProvideValues,
} from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Storage counter app', () => { describe('Storage counter app', () => {
let wrapper; let wrapper;
const createMockApolloProvider = ({ mutationMock }) => { const createMockApolloProvider = ({ reject = false, mockedValue } = {}) => {
localVue.use(VueApollo); let response;
const requestHandlers = [[getProjectStorageCount, mutationMock]]; if (reject) {
response = jest.fn().mockRejectedValue(mockedValue || new Error('GraphQL error'));
} else {
response = jest.fn().mockResolvedValue(mockedValue);
}
const requestHandlers = [[getProjectStorageCount, response]];
return createMockApollo(requestHandlers); return createMockApollo(requestHandlers);
}; };
const createComponent = ({ provide = {}, mockApollo } = {}) => { const createComponent = ({ provide = {}, mockApollo } = {}) => {
const defaultProvideValues = { wrapper = extendedWrapper(
projectPath: 'test-project', shallowMount(StorageCounterApp, {
}; localVue,
apolloProvider: mockApollo,
wrapper = shallowMount(StorageCounterApp, { provide: {
localVue, ...defaultProvideValues,
apolloProvider: mockApollo, ...provide,
provide: { },
...defaultProvideValues, }),
...provide, );
},
});
}; };
const findAlert = () => wrapper.findComponent(GlAlert); const findAlert = () => wrapper.findComponent(GlAlert);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findUsagePercentage = () => wrapper.findByTestId('total-usage');
const findUsageQuotasHelpLink = () => wrapper.findByTestId('usage-quotas-help-link');
const findUsageGraph = () => wrapper.findComponent(UsageGraph); const findUsageGraph = () => wrapper.findComponent(UsageGraph);
beforeEach(() => {
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
it('renders app successfully', () => { describe('with apollo fetching successful', () => {
expect(wrapper.text()).toBe('Usage'); let mockApollo;
beforeEach(async () => {
mockApollo = createMockApolloProvider({
mockedValue: mockGetProjectStorageCountGraphQLResponse,
});
createComponent({ mockApollo });
await waitForPromises();
});
it('renders correct total usage', () => {
expect(findUsagePercentage().text()).toBe(projectData.storage.totalUsage);
});
it('renders correct usage quotas help link', () => {
expect(findUsageQuotasHelpLink().attributes('href')).toBe(
defaultProvideValues.helpLinks.usageQuotasHelpPagePath,
);
});
}); });
describe('handling apollo fetching error', () => { describe('with apollo loading', () => {
const mutationMock = jest.fn().mockRejectedValue(new Error('GraphQL error')); let mockApollo;
beforeEach(() => { beforeEach(() => {
const mockApollo = createMockApolloProvider({ mutationMock }); mockApollo = createMockApolloProvider({
mockedValue: new Promise(() => {}),
});
createComponent({ mockApollo });
});
it('should show loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('with apollo returning empty data', () => {
let mockApollo;
beforeEach(async () => {
mockApollo = createMockApolloProvider({
mockedValue: mockEmptyResponse,
});
createComponent({ mockApollo }); createComponent({ mockApollo });
await waitForPromises();
}); });
it('renders gl-alert if there is an error', () => { it('shows default text for total usage', () => {
expect(findUsagePercentage().text()).toBe(TOTAL_USAGE_DEFAULT_TEXT);
});
});
describe('with apollo fetching error', () => {
let mockApollo;
beforeEach(() => {
mockApollo = createMockApolloProvider();
createComponent({ mockApollo, reject: true });
});
it('renders gl-alert', () => {
expect(findAlert().exists()).toBe(true); expect(findAlert().exists()).toBe(true);
}); });
}); });
describe('rendering <usage-graph />', () => { describe('rendering <usage-graph />', () => {
const mutationMock = jest.fn().mockResolvedValue(projectStorageCountResponse); let mockApollo;
beforeEach(() => { beforeEach(async () => {
const mockApollo = createMockApolloProvider({ mutationMock }); mockApollo = createMockApolloProvider({
mockedValue: mockGetProjectStorageCountGraphQLResponse,
});
createComponent({ mockApollo }); createComponent({ mockApollo });
await waitForPromises();
}); });
it('renders usage-graph component if project.statistics exists', () => { it('renders usage-graph component if project.statistics exists', () => {
...@@ -76,7 +140,10 @@ describe('Storage counter app', () => { ...@@ -76,7 +140,10 @@ describe('Storage counter app', () => {
}); });
it('passes project.statistics to usage-graph component', () => { it('passes project.statistics to usage-graph component', () => {
const { __typename, ...statistics } = projectStorageCountResponse.data.project.statistics; const {
__typename,
...statistics
} = mockGetProjectStorageCountGraphQLResponse.data.project.statistics;
expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics); expect(findUsageGraph().props('rootStorageStatistics')).toMatchObject(statistics);
}); });
}); });
......
import { GlTable } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import StorageTable from '~/projects/storage_counter/components/storage_table.vue';
import { projectData, defaultProvideValues } from '../mock_data';
describe('StorageTable', () => {
let wrapper;
const defaultProps = {
storageTypes: projectData.storage.storageTypes,
};
const createComponent = (props = {}) => {
wrapper = extendedWrapper(
mount(StorageTable, {
propsData: {
...defaultProps,
...props,
},
}),
);
};
const findTable = () => wrapper.findComponent(GlTable);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('with storage types', () => {
it.each(projectData.storage.storageTypes)(
'renders table row correctly %o',
({ storageType: { id, name, description } }) => {
expect(wrapper.findByTestId(`${id}-name`).text()).toBe(name);
expect(wrapper.findByTestId(`${id}-description`).text()).toBe(description);
expect(wrapper.findByTestId(`${id}-help-link`).attributes('href')).toBe(
defaultProvideValues.helpLinks[id.replace(`Size`, `HelpPagePath`)]
.replace(`Size`, ``)
.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`),
);
},
);
});
describe('without storage types', () => {
beforeEach(() => {
createComponent({ storageTypes: [] });
});
it('should render the table header <th>', () => {
expect(findTable().find('th').exists()).toBe(true);
});
it('should not render any table data <td>', () => {
expect(findTable().find('td').exists()).toBe(false);
});
});
});
export const projectStorageCountResponse = { export const mockGetProjectStorageCountGraphQLResponse = {
data: { data: {
project: { project: {
id: 'gid://gitlab/Project/20', id: 'gid://gitlab/Project/20',
statistics: { statistics: {
buildArtifactsSize: 400000, buildArtifactsSize: 400000.0,
lfsObjectsSize: 4800000, lfsObjectsSize: 4800000.0,
packagesSize: 3800000, packagesSize: 3800000.0,
repositorySize: 39000000, repositorySize: 3900000.0,
snippetsSize: 0, snippetsSize: 1200000.0,
storageSize: 39930000, storageSize: 15300000.0,
uploadsSize: 0, uploadsSize: 900000.0,
wikiSize: 300000, wikiSize: 300000.0,
__typename: 'ProjectStatistics', __typename: 'ProjectStatistics',
}, },
__typename: 'Project', __typename: 'Project',
}, },
}, },
}; };
export const mockEmptyResponse = { data: { project: null } };
export const defaultProvideValues = {
projectPath: '/project-path',
helpLinks: {
usageQuotasHelpPagePath: '/usage-quotas',
buildArtifactsHelpPagePath: '/build-artifacts',
lfsObjectsHelpPagePath: '/lsf-objects',
packagesHelpPagePath: '/packages',
repositoryHelpPagePath: '/repository',
snippetsHelpPagePath: '/snippets',
uploadsHelpPagePath: '/uploads',
wikiHelpPagePath: '/wiki',
},
};
export const projectData = {
storage: {
totalUsage: '14.6 MiB',
storageTypes: [
{
storageType: {
id: 'buildArtifactsSize',
name: 'Artifacts',
description: 'Pipeline artifacts and job artifacts, created with CI/CD.',
warningMessage:
'There is a known issue with Artifact storage where the total could be incorrect for some projects. More details and progress are available in %{warningLinkStart}the epic%{warningLinkEnd}.',
helpPath: '/build-artifacts',
},
value: '390.6 KiB',
},
{
storageType: {
id: 'lfsObjectsSize',
name: 'LFS Storage',
description: 'Audio samples, videos, datasets, and graphics.',
helpPath: '/lsf-objects',
},
value: '4.6 MiB',
},
{
storageType: {
id: 'packagesSize',
name: 'Packages',
description: 'Code packages and container images.',
helpPath: '/packages',
},
value: '3.6 MiB',
},
{
storageType: {
id: 'repositorySize',
name: 'Repository',
description: 'Git repository, managed by the Gitaly service.',
helpPath: '/repository',
},
value: '3.7 MiB',
},
{
storageType: {
id: 'snippetsSize',
name: 'Snippets',
description: 'Shared bits of code and text.',
helpPath: '/snippets',
},
value: '1.1 MiB',
},
{
storageType: {
id: 'uploadsSize',
name: 'Uploads',
description: 'File attachments and smaller design graphics.',
helpPath: '/uploads',
},
value: '878.9 KiB',
},
{
storageType: {
id: 'wikiSize',
name: 'Wiki',
description: 'Wiki content.',
helpPath: '/wiki',
},
value: '293.0 KiB',
},
],
},
};
import { parseGetProjectStorageResults } from '~/projects/storage_counter/utils';
import {
mockGetProjectStorageCountGraphQLResponse,
projectData,
defaultProvideValues,
} from './mock_data';
describe('parseGetProjectStorageResults', () => {
it('parses project statistics correctly', () => {
expect(
parseGetProjectStorageResults(
mockGetProjectStorageCountGraphQLResponse.data,
defaultProvideValues.helpLinks,
),
).toMatchObject(projectData);
});
});
...@@ -28,10 +28,20 @@ RSpec.describe 'Project Usage Quotas' do ...@@ -28,10 +28,20 @@ RSpec.describe 'Project Usage Quotas' do
end end
it 'renders usage quotas path' do it 'renders usage quotas path' do
mock_storage_app_data = {
project_path: project.full_path,
usage_quotas_help_page_path: help_page_path('user/usage_quotas'),
build_artifacts_help_page_path: help_page_path('ci/pipelines/job_artifacts', anchor: 'when-job-artifacts-are-deleted'),
packages_help_page_path: help_page_path('user/packages/package_registry/index.md', anchor: 'delete-a-package'),
repository_help_page_path: help_page_path('user/project/repository/reducing_the_repo_size_using_git'),
snippets_help_page_path: help_page_path('user/snippets', anchor: 'reduce-snippets-repository-size'),
wiki_help_page_path: help_page_path('administration/wikis/index.md', anchor: 'reduce-wiki-repository-size')
}
get project_usage_quotas_path(project) get project_usage_quotas_path(project)
expect(response).to have_gitlab_http_status(:ok) expect(response).to have_gitlab_http_status(:ok)
expect(response.body).to include(project_usage_quotas_path(project)) expect(response.body).to include(project_usage_quotas_path(project))
expect(assigns[:storage_app_data]).to eq(mock_storage_app_data)
expect(response.body).to include("Usage of project resources across the <strong>#{project.name}</strong> project") expect(response.body).to include("Usage of project resources across the <strong>#{project.name}</strong> project")
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment