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_KB = 1000;
export const HIDDEN_CLASS = 'hidden';
export const TRUNCATE_WIDTH_DEFAULT_WIDTH = 80;
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';
/**
......@@ -34,6 +34,18 @@ export function formatRelevantDigits(number) {
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.
*
......
<script>
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 UsageGraph from './usage_graph.vue';
import UsageStatistics from './usage_statistics.vue';
import query from '../queries/storage.query.graphql';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
......@@ -16,11 +18,13 @@ export default {
GlSprintf,
GlIcon,
UsageGraph,
UsageStatistics,
TemporaryStorageIncreaseModal,
},
directives: {
GlModalDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
namespacePath: {
type: String,
......@@ -78,6 +82,9 @@ export default {
isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible);
},
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
},
methods: {
formatSize(size) {
......@@ -89,9 +96,12 @@ export default {
</script>
<template>
<div>
<div class="pipeline-quota container-fluid py-4 px-2 m-0">
<div class="row py-0 d-flex align-items-center">
<div class="col-lg-6">
<div v-if="isAdditionalStorageFlagEnabled && namespace.rootStorageStatistics">
<usage-statistics :root-storage-statistics="namespace.rootStorageStatistics" />
</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}')">
<template #usage>
<span class="gl-font-weight-bold" data-testid="total-usage">
......@@ -117,7 +127,7 @@ export default {
<gl-icon name="question" :size="12" />
</gl-link>
</div>
<div class="col-lg-6 text-lg-right">
<div class="gl-w-half gl-text-right">
<gl-button
v-if="isStorageIncreaseModalVisible"
v-gl-modal-directive="$options.modalId"
......@@ -136,14 +146,11 @@ export default {
>
</div>
</div>
<div class="row py-0">
<div class="col-sm-12">
<usage-graph
v-if="namespace.rootStorageStatistics"
:root-storage-statistics="namespace.rootStorageStatistics"
:limit="namespace.limit"
/>
</div>
<div v-if="namespace.rootStorageStatistics" class="gl-w-full">
<usage-graph
:root-storage-statistics="namespace.rootStorageStatistics"
:limit="namespace.limit"
/>
</div>
</div>
<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 StorageApp from 'ee/storage_counter/components/app.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 { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { namespaceData, withRootStorageStatistics } from '../mock_data';
......@@ -15,8 +17,14 @@ describe('Storage counter app', () => {
const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']");
const findTemporaryStorageIncreaseButton = () =>
wrapper.find("[data-testid='temporary-storage-increase-button']");
function createComponent(props = {}, loading = false) {
const findUsageGraph = () => wrapper.find(UsageGraph);
const findUsageStatistics = () => wrapper.find(UsageStatistics);
const createComponent = ({
props = {},
loading = false,
additionalRepoStorageByNamespace = false,
} = {}) => {
const $apollo = {
queries: {
namespace: {
......@@ -31,8 +39,13 @@ describe('Storage counter app', () => {
directives: {
GlModalDirective: createMockDirective(),
},
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
},
},
});
}
};
beforeEach(() => {
createComponent();
......@@ -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', () => {
it('renders N/A', async () => {
wrapper.setData({
......@@ -107,7 +148,7 @@ describe('Storage counter app', () => {
describe('when purchaseStorageUrl is set', () => {
beforeEach(() => {
createComponent({ purchaseStorageUrl: 'customers.gitlab.com' });
createComponent({ props: { purchaseStorageUrl: 'customers.gitlab.com' } });
});
it('does render link', () => {
......@@ -127,7 +168,7 @@ describe('Storage counter app', () => {
${{ isTemporaryStorageIncreaseVisible: 'true' }} | ${true}
`('with $props', ({ props, isVisible }) => {
beforeEach(() => {
createComponent(props);
createComponent({ props });
});
it(`renders button = ${isVisible}`, () => {
......@@ -137,7 +178,7 @@ describe('Storage counter app', () => {
describe('when temporary storage increase is visible', () => {
beforeEach(() => {
createComponent({ isTemporaryStorageIncreaseVisible: 'true' });
createComponent({ props: { isTemporaryStorageIncreaseVisible: 'true' } });
wrapper.setData({
namespace: {
...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 = {
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
msgstr[0] ""
msgstr[1] ""
msgid "%{size} %{unit}"
msgstr ""
msgid "%{size} GiB"
msgstr ""
......@@ -28102,6 +28105,12 @@ msgstr ""
msgid "UsageQuota|LFS Storage"
msgstr ""
msgid "UsageQuota|Learn more about excess storage usage"
msgstr ""
msgid "UsageQuota|Learn more about usage quotas"
msgstr ""
msgid "UsageQuota|Packages"
msgstr ""
......@@ -28111,6 +28120,9 @@ msgstr ""
msgid "UsageQuota|Purchase more storage"
msgstr ""
msgid "UsageQuota|Purchased storage available"
msgstr ""
msgid "UsageQuota|Repositories"
msgstr ""
......@@ -28135,6 +28147,12 @@ msgstr ""
msgid "UsageQuota|This project is locked."
msgstr ""
msgid "UsageQuota|Total excess storage used"
msgstr ""
msgid "UsageQuota|Total namespace storage used"
msgstr ""
msgid "UsageQuota|Unlimited"
msgstr ""
......
import {
formatRelevantDigits,
bytesToKB,
bytesToKiB,
bytesToMiB,
bytesToGiB,
......@@ -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', () => {
it('calculates KiB for the given bytes', () => {
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