Commit 3ee0a7e1 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch 'usage-quota-statistics-boxes' into 'master'

Add usage stats to quotas page

See merge request gitlab-org/gitlab!44221
parents 9c424b27 2c029fcf
export const BYTES_IN_KIB = 1024; export const BYTES_IN_KIB = 1024;
export const BYTES_IN_KB = 1000;
export const HIDDEN_CLASS = 'hidden'; export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80; export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12; export const TRUNCATE_WIDTH_DEFAULT_FONT_SIZE = 12;
......
import { BYTES_IN_KIB } from './constants'; import { BYTES_IN_KIB, BYTES_IN_KB } from './constants';
import { sprintf, __ } from '~/locale'; import { sprintf, __ } from '~/locale';
/** /**
...@@ -34,6 +34,18 @@ export function formatRelevantDigits(number) { ...@@ -34,6 +34,18 @@ export function formatRelevantDigits(number) {
return formattedNumber; return formattedNumber;
} }
/**
* Utility function that calculates KB of the given bytes.
* Note: This method calculates KiloBytes as opposed to
* Kibibytes. For Kibibytes, bytesToKiB should be used.
*
* @param {Number} number bytes
* @return {Number} KiB
*/
export function bytesToKB(number) {
return number / BYTES_IN_KB;
}
/** /**
* Utility function that calculates KiB of the given bytes. * Utility function that calculates KiB of the given bytes.
* *
......
<script> <script>
import { GlLink, GlSprintf, GlModalDirective, GlButton, GlIcon } from '@gitlab/ui'; import { GlLink, GlSprintf, GlModalDirective, GlButton, GlIcon } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ProjectsTable from './projects_table.vue'; import ProjectsTable from './projects_table.vue';
import UsageGraph from './usage_graph.vue'; import UsageGraph from './usage_graph.vue';
import UsageStatistics from './usage_statistics.vue';
import query from '../queries/storage.query.graphql'; import query from '../queries/storage.query.graphql';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue'; import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
...@@ -16,11 +18,13 @@ export default { ...@@ -16,11 +18,13 @@ export default {
GlSprintf, GlSprintf,
GlIcon, GlIcon,
UsageGraph, UsageGraph,
UsageStatistics,
TemporaryStorageIncreaseModal, TemporaryStorageIncreaseModal,
}, },
directives: { directives: {
GlModalDirective, GlModalDirective,
}, },
mixins: [glFeatureFlagsMixin()],
props: { props: {
namespacePath: { namespacePath: {
type: String, type: String,
...@@ -78,6 +82,9 @@ export default { ...@@ -78,6 +82,9 @@ export default {
isStorageIncreaseModalVisible() { isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible); return parseBoolean(this.isTemporaryStorageIncreaseVisible);
}, },
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
}, },
methods: { methods: {
formatSize(size) { formatSize(size) {
...@@ -89,9 +96,12 @@ export default { ...@@ -89,9 +96,12 @@ export default {
</script> </script>
<template> <template>
<div> <div>
<div class="pipeline-quota container-fluid py-4 px-2 m-0"> <div v-if="isAdditionalStorageFlagEnabled && namespace.rootStorageStatistics">
<div class="row py-0 d-flex align-items-center"> <usage-statistics :root-storage-statistics="namespace.rootStorageStatistics" />
<div class="col-lg-6"> </div>
<div v-else class="gl-py-4 gl-px-2 gl-m-0">
<div class="gl-display-flex gl-align-items-center">
<div class="gl-w-half">
<gl-sprintf :message="s__('UsageQuota|You used: %{usage} %{limit}')"> <gl-sprintf :message="s__('UsageQuota|You used: %{usage} %{limit}')">
<template #usage> <template #usage>
<span class="gl-font-weight-bold" data-testid="total-usage"> <span class="gl-font-weight-bold" data-testid="total-usage">
...@@ -117,7 +127,7 @@ export default { ...@@ -117,7 +127,7 @@ export default {
<gl-icon name="question" :size="12" /> <gl-icon name="question" :size="12" />
</gl-link> </gl-link>
</div> </div>
<div class="col-lg-6 text-lg-right"> <div class="gl-w-half gl-text-right">
<gl-button <gl-button
v-if="isStorageIncreaseModalVisible" v-if="isStorageIncreaseModalVisible"
v-gl-modal-directive="$options.modalId" v-gl-modal-directive="$options.modalId"
...@@ -136,14 +146,11 @@ export default { ...@@ -136,14 +146,11 @@ export default {
> >
</div> </div>
</div> </div>
<div class="row py-0"> <div v-if="namespace.rootStorageStatistics" class="gl-w-full">
<div class="col-sm-12"> <usage-graph
<usage-graph :root-storage-statistics="namespace.rootStorageStatistics"
v-if="namespace.rootStorageStatistics" :limit="namespace.limit"
:root-storage-statistics="namespace.rootStorageStatistics" />
:limit="namespace.limit"
/>
</div>
</div> </div>
</div> </div>
<projects-table :projects="namespaceProjects" /> <projects-table :projects="namespaceProjects" />
......
<script>
import { GlButton } from '@gitlab/ui';
import UsageStatisticsCard from './usage_statistics_card.vue';
import { s__ } from '~/locale';
import { bytesToKB } from '~/lib/utils/number_utils';
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
export default {
components: {
GlButton,
UsageStatisticsCard,
},
props: {
rootStorageStatistics: {
required: true,
type: Object,
},
},
computed: {
totalUsage() {
const { repositorySize = 0, lfsObjectsSize = 0 } = this.rootStorageStatistics;
return {
usage: this.formatSize(repositorySize + lfsObjectsSize),
description: s__('UsageQuota|Total namespace storage used'),
link: {
text: s__('UsageQuota|Learn more about usage quotas'),
url: '#',
},
};
},
excessUsage() {
return {
usage: this.formatSize(0),
description: s__('UsageQuota|Total excess storage used'),
link: {
text: s__('UsageQuota|Learn more about excess storage usage'),
url: '#',
},
};
},
purchasedUsage() {
return {
usage: this.formatSize(0),
description: s__('UsageQuota|Purchased storage available'),
link: {
text: s__('UsageQuota|Purchase more storage'),
url: '#',
},
};
},
},
methods: {
/**
* The formatDecimalBytes method returns
* value along with the unit. However, the unit
* and the value needs to be separated so that
* they can have different styles. The method
* splits the value into value and unit.
*
* We want to display all units above bytes. Hence
* converting bytesToKB before passing it to
* `getFormatter`
*
* @params {Number} size size in bytes
* @returns {Object} value and unit of formatted size
*/
formatSize(size) {
const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.kilobytes);
const formattedSize = formatDecimalBytes(bytesToKB(size), 1);
return {
value: formattedSize.slice(0, -2),
unit: formattedSize.slice(-2),
};
},
},
};
</script>
<template>
<div class="gl-display-flex gl-sm-flex-direction-column">
<usage-statistics-card
data-testid="totalUsage"
:usage="totalUsage.usage"
:link="totalUsage.link"
:description="totalUsage.description"
css-class="gl-mr-4"
/>
<usage-statistics-card
data-testid="excessUsage"
:usage="excessUsage.usage"
:link="excessUsage.link"
:description="excessUsage.description"
css-class="gl-mx-4"
/>
<usage-statistics-card
data-testid="purchasedUsage"
:usage="purchasedUsage.usage"
:link="purchasedUsage.link"
:description="purchasedUsage.description"
css-class="gl-ml-4"
>
<template #link="{link}">
<gl-button
target="_blank"
:href="link.url"
class="mb-0"
variant="success"
category="primary"
block
>
{{ link.text }}
</gl-button>
</template>
</usage-statistics-card>
</div>
</template>
<script>
import { GlLink, GlIcon, GlSprintf } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlLink,
GlSprintf,
},
props: {
link: {
type: Object,
required: false,
default: () => ({ text: '', url: '' }),
},
description: {
type: String,
required: true,
},
usage: {
type: Object,
required: true,
},
cssClass: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="gl-p-5 gl-my-5 gl-bg-gray-10 gl-flex-fill-1 gl-white-space-nowrap" :class="cssClass">
<p class="mb-2">
<gl-sprintf :message="__('%{size} %{unit}')">
<template #size>
<span class="gl-font-size-h-display gl-font-weight-bold">{{ usage.value }}</span>
</template>
<template #unit>
<span class="gl-font-lg gl-font-weight-bold">{{ usage.unit }}</span>
</template>
</gl-sprintf>
</p>
<p class="gl-border-b-2 gl-border-b-solid gl-border-b-gray-100 gl-font-weight-bold gl-pb-3">
{{ description }}
</p>
<p class="gl-mb-0">
<slot v-bind="{ link }" name="link">
<gl-link target="_blank" :href="link.url">
<span class="text-truncate">{{ link.text }}</span>
<gl-icon name="external-link" class="gl-ml-2 gl-flex-shrink-0 gl-text-black-normal" />
</gl-link>
</slot>
</p>
</div>
</template>
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import StorageApp from 'ee/storage_counter/components/app.vue'; import StorageApp from 'ee/storage_counter/components/app.vue';
import Project from 'ee/storage_counter/components/project.vue'; import Project from 'ee/storage_counter/components/project.vue';
import UsageGraph from 'ee/storage_counter/components/usage_graph.vue';
import UsageStatistics from 'ee/storage_counter/components/usage_statistics.vue';
import TemporaryStorageIncreaseModal from 'ee/storage_counter/components/temporary_storage_increase_modal.vue'; import TemporaryStorageIncreaseModal from 'ee/storage_counter/components/temporary_storage_increase_modal.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { namespaceData, withRootStorageStatistics } from '../mock_data'; import { namespaceData, withRootStorageStatistics } from '../mock_data';
...@@ -15,8 +17,14 @@ describe('Storage counter app', () => { ...@@ -15,8 +17,14 @@ describe('Storage counter app', () => {
const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']"); const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']");
const findTemporaryStorageIncreaseButton = () => const findTemporaryStorageIncreaseButton = () =>
wrapper.find("[data-testid='temporary-storage-increase-button']"); wrapper.find("[data-testid='temporary-storage-increase-button']");
const findUsageGraph = () => wrapper.find(UsageGraph);
function createComponent(props = {}, loading = false) { const findUsageStatistics = () => wrapper.find(UsageStatistics);
const createComponent = ({
props = {},
loading = false,
additionalRepoStorageByNamespace = false,
} = {}) => {
const $apollo = { const $apollo = {
queries: { queries: {
namespace: { namespace: {
...@@ -31,8 +39,13 @@ describe('Storage counter app', () => { ...@@ -31,8 +39,13 @@ describe('Storage counter app', () => {
directives: { directives: {
GlModalDirective: createMockDirective(), GlModalDirective: createMockDirective(),
}, },
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
},
},
}); });
} };
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -86,6 +99,34 @@ describe('Storage counter app', () => { ...@@ -86,6 +99,34 @@ describe('Storage counter app', () => {
}); });
}); });
describe('with additional_repo_storage_by_namespace feature flag', () => {
it('usage_graph component hidden is when flag is false', async () => {
wrapper.setData({
namespace: withRootStorageStatistics,
});
await wrapper.vm.$nextTick();
expect(findUsageGraph().exists()).toBe(true);
expect(findUsageStatistics().exists()).toBe(false);
});
it('usage_statistics component is rendered when flag is true', async () => {
createComponent({
additionalRepoStorageByNamespace: true,
});
wrapper.setData({
namespace: withRootStorageStatistics,
});
await wrapper.vm.$nextTick();
expect(findUsageStatistics().exists()).toBe(true);
expect(findUsageGraph().exists()).toBe(false);
});
});
describe('without rootStorageStatistics information', () => { describe('without rootStorageStatistics information', () => {
it('renders N/A', async () => { it('renders N/A', async () => {
wrapper.setData({ wrapper.setData({
...@@ -107,7 +148,7 @@ describe('Storage counter app', () => { ...@@ -107,7 +148,7 @@ describe('Storage counter app', () => {
describe('when purchaseStorageUrl is set', () => { describe('when purchaseStorageUrl is set', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ purchaseStorageUrl: 'customers.gitlab.com' }); createComponent({ props: { purchaseStorageUrl: 'customers.gitlab.com' } });
}); });
it('does render link', () => { it('does render link', () => {
...@@ -127,7 +168,7 @@ describe('Storage counter app', () => { ...@@ -127,7 +168,7 @@ describe('Storage counter app', () => {
${{ isTemporaryStorageIncreaseVisible: 'true' }} | ${true} ${{ isTemporaryStorageIncreaseVisible: 'true' }} | ${true}
`('with $props', ({ props, isVisible }) => { `('with $props', ({ props, isVisible }) => {
beforeEach(() => { beforeEach(() => {
createComponent(props); createComponent({ props });
}); });
it(`renders button = ${isVisible}`, () => { it(`renders button = ${isVisible}`, () => {
...@@ -137,7 +178,7 @@ describe('Storage counter app', () => { ...@@ -137,7 +178,7 @@ describe('Storage counter app', () => {
describe('when temporary storage increase is visible', () => { describe('when temporary storage increase is visible', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ isTemporaryStorageIncreaseVisible: 'true' }); createComponent({ props: { isTemporaryStorageIncreaseVisible: 'true' } });
wrapper.setData({ wrapper.setData({
namespace: { namespace: {
...namespaceData, ...namespaceData,
......
import { shallowMount } from '@vue/test-utils';
import { GlButton, GlLink } from '@gitlab/ui';
import UsageStatistics from 'ee/storage_counter/components/usage_statistics.vue';
import UsageStatisticsCard from 'ee/storage_counter/components/usage_statistics_card.vue';
import { withRootStorageStatistics } from '../mock_data';
describe('Usage Statistics component', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(UsageStatistics, {
propsData: {
rootStorageStatistics: withRootStorageStatistics.rootStorageStatistics,
},
stubs: {
UsageStatisticsCard,
GlLink,
},
});
};
const getStatisticsCards = () => wrapper.findAll(UsageStatisticsCard);
const getStatisticsCard = testId => wrapper.find(`[data-testid="${testId}"]`);
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders three statistics cards', () => {
expect(getStatisticsCards()).toHaveLength(3);
});
it.each`
cardName | componentName | componentType
${'totalUsage'} | ${'GlLink'} | ${GlLink}
${'excessUsage'} | ${'GlLink'} | ${GlLink}
${'purchasedUsage'} | ${'GlButton'} | ${GlButton}
`('renders $componentName in $cardName', ({ cardName, componentType }) => {
expect(
getStatisticsCard(cardName)
.find(componentType)
.exists(),
).toBe(true);
});
});
...@@ -55,4 +55,17 @@ export const namespaceData = { ...@@ -55,4 +55,17 @@ export const namespaceData = {
projects, projects,
}; };
export const withRootStorageStatistics = { ...projects, totalUsage: 3261070 }; export const withRootStorageStatistics = {
projects,
limit: 10000000,
totalUsage: 129334601,
rootStorageStatistics: {
storageSize: 129334601,
repositorySize: 46012030,
lfsObjectsSize: 4329334601203,
buildArtifactsSize: 1272375,
packagesSize: 123123120,
wikiSize: 1000,
snippetsSize: 10000,
},
};
...@@ -746,6 +746,9 @@ msgid_plural "%{securityScanner} results are not available because a pipeline ha ...@@ -746,6 +746,9 @@ msgid_plural "%{securityScanner} results are not available because a pipeline ha
msgstr[0] "" msgstr[0] ""
msgstr[1] "" msgstr[1] ""
msgid "%{size} %{unit}"
msgstr ""
msgid "%{size} GiB" msgid "%{size} GiB"
msgstr "" msgstr ""
...@@ -28102,6 +28105,12 @@ msgstr "" ...@@ -28102,6 +28105,12 @@ msgstr ""
msgid "UsageQuota|LFS Storage" msgid "UsageQuota|LFS Storage"
msgstr "" msgstr ""
msgid "UsageQuota|Learn more about excess storage usage"
msgstr ""
msgid "UsageQuota|Learn more about usage quotas"
msgstr ""
msgid "UsageQuota|Packages" msgid "UsageQuota|Packages"
msgstr "" msgstr ""
...@@ -28111,6 +28120,9 @@ msgstr "" ...@@ -28111,6 +28120,9 @@ msgstr ""
msgid "UsageQuota|Purchase more storage" msgid "UsageQuota|Purchase more storage"
msgstr "" msgstr ""
msgid "UsageQuota|Purchased storage available"
msgstr ""
msgid "UsageQuota|Repositories" msgid "UsageQuota|Repositories"
msgstr "" msgstr ""
...@@ -28135,6 +28147,12 @@ msgstr "" ...@@ -28135,6 +28147,12 @@ msgstr ""
msgid "UsageQuota|This project is locked." msgid "UsageQuota|This project is locked."
msgstr "" msgstr ""
msgid "UsageQuota|Total excess storage used"
msgstr ""
msgid "UsageQuota|Total namespace storage used"
msgstr ""
msgid "UsageQuota|Unlimited" msgid "UsageQuota|Unlimited"
msgstr "" msgstr ""
......
import { import {
formatRelevantDigits, formatRelevantDigits,
bytesToKB,
bytesToKiB, bytesToKiB,
bytesToMiB, bytesToMiB,
bytesToGiB, bytesToGiB,
...@@ -54,6 +55,16 @@ describe('Number Utils', () => { ...@@ -54,6 +55,16 @@ describe('Number Utils', () => {
}); });
}); });
describe('bytesToKB', () => {
it.each`
input | output
${1000} | ${1}
${1024} | ${1.024}
`('returns $output KB for $input bytes', ({ input, output }) => {
expect(bytesToKB(input)).toBe(output);
});
});
describe('bytesToKiB', () => { describe('bytesToKiB', () => {
it('calculates KiB for the given bytes', () => { it('calculates KiB for the given bytes', () => {
expect(bytesToKiB(1024)).toEqual(1); expect(bytesToKiB(1024)).toEqual(1);
......
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