Commit 2c029fcf authored by Dhiraj Bodicherla's avatar Dhiraj Bodicherla Committed by Vitaly Slobodin

Add usage stats to quotas page

This MR adds more info about usage statistics
in the Usage Quotas page
parent 83fe18a3
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 ""
...@@ -28072,6 +28075,12 @@ msgstr "" ...@@ -28072,6 +28075,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 ""
...@@ -28081,6 +28090,9 @@ msgstr "" ...@@ -28081,6 +28090,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 ""
...@@ -28102,6 +28114,12 @@ msgstr "" ...@@ -28102,6 +28114,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