Commit dbf27467 authored by nicolasdular's avatar nicolasdular

Render storage usage graph with a limit

When a limit is set, users should see how much of their storage
usage gets used in the storage usage graph. For the case when the
limit is either 0 or the current usage exceeds the limit, we just
render the distribution.

We will handle the design of exceeded usage at a later stage.
parent e1c83c24
<script>
import { GlLink } from '@gitlab/ui';
import { GlLink, GlSprintf } from '@gitlab/ui';
import Project from './project.vue';
import UsageGraph from './usage_graph.vue';
import query from '../queries/storage.graphql';
......@@ -10,6 +10,7 @@ export default {
components: {
Project,
GlLink,
GlSprintf,
Icon,
UsageGraph,
},
......@@ -44,6 +45,7 @@ export default {
? numberToHumanSize(data.namespace.rootStorageStatistics.storageSize)
: 'N/A',
rootStorageStatistics: data.namespace.rootStorageStatistics,
limit: data.namespace.storageSizeLimit,
}),
},
},
......@@ -52,6 +54,11 @@ export default {
namespace: {},
};
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
},
};
</script>
<template>
......@@ -59,9 +66,23 @@ export default {
<div class="pipeline-quota container-fluid py-4 px-2 m-0">
<div class="row py-0">
<div class="col-sm-12">
<strong>{{ s__('UsageQuota|Storage usage:') }}</strong>
<span data-testid="total-usage">
<gl-sprintf :message="s__('UsageQuota|You used: %{usage} %{limit}')">
<template #usage>
<span class="gl-font-weight-bold" data-testid="total-usage">
{{ namespace.totalUsage }}
</span>
</template>
<template #limit>
<gl-sprintf
v-if="namespace.limit"
:message="s__('UsageQuota|out of %{formattedLimit} of your namespace storage')"
>
<template #formattedLimit>
<span class="gl-font-weight-bold">{{ formatSize(namespace.limit) }}</span>
</template>
</gl-sprintf>
</template>
</gl-sprintf>
<gl-link
:href="helpPagePath"
target="_blank"
......@@ -69,7 +90,6 @@ export default {
>
<icon name="question" :size="12" />
</gl-link>
</span>
</div>
</div>
<div class="row py-0">
......@@ -77,6 +97,7 @@ export default {
<usage-graph
v-if="namespace.rootStorageStatistics"
:root-storage-statistics="namespace.rootStorageStatistics"
:limit="namespace.limit"
/>
</div>
</div>
......
......@@ -8,6 +8,10 @@ export default {
required: true,
type: Object,
},
limit: {
required: true,
type: Number,
},
},
computed: {
storageTypes() {
......@@ -27,31 +31,31 @@ export default {
return [
{
name: s__('UsageQuota|Repositories'),
percentage: this.sizePercentage(repositorySize),
style: this.usageStyle(this.sizePercentage(repositorySize)),
class: 'gl-bg-data-viz-blue-500',
size: repositorySize,
},
{
name: s__('UsageQuota|LFS Objects'),
percentage: this.sizePercentage(lfsObjectsSize),
style: this.usageStyle(this.sizePercentage(lfsObjectsSize)),
class: 'gl-bg-data-viz-orange-600',
size: lfsObjectsSize,
},
{
name: s__('UsageQuota|Packages'),
percentage: this.sizePercentage(packagesSize),
style: this.usageStyle(this.sizePercentage(packagesSize)),
class: 'gl-bg-data-viz-aqua-500',
size: packagesSize,
},
{
name: s__('UsageQuota|Build Artifacts'),
percentage: this.sizePercentage(buildArtifactsSize),
style: this.usageStyle(this.sizePercentage(buildArtifactsSize)),
class: 'gl-bg-data-viz-green-600',
size: buildArtifactsSize,
},
{
name: s__('UsageQuota|Wikis'),
percentage: this.sizePercentage(wikiSize),
style: this.usageStyle(this.sizePercentage(wikiSize)),
class: 'gl-bg-data-viz-magenta-500',
size: wikiSize,
},
......@@ -64,23 +68,31 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
usageStyle(percentage) {
return { width: `${percentage.toFixed()}%` };
},
sizePercentage(size) {
const { storageSize } = this.rootStorageStatistics;
let max = this.rootStorageStatistics.storageSize;
if (this.limit !== 0 && max <= this.limit) {
max = this.limit;
}
return (size / storageSize) * 100;
return (size / max) * 100;
},
},
};
</script>
<template>
<div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
<div class="gl-h-6 my-3">
<div class="gl-h-6 my-3 gl-bg-gray-50 gl-rounded-base">
<div
v-for="storageType in storageTypes"
:key="storageType.name"
class="storage-type-usage gl-h-full gl-display-inline-block"
:class="storageType.class"
:style="{ width: `${storageType.percentage}%` }"
:style="storageType.style"
data-testid="storage-type-usage"
></div>
</div>
<div class="row py-0">
......@@ -88,7 +100,7 @@ export default {
v-for="storageType in storageTypes"
:key="storageType.name"
class="col-md-auto gl-display-flex gl-align-items-center"
data-testid="storage-type"
data-testid="storage-type-legend"
>
<div class="gl-h-2 gl-w-5 gl-mr-2 gl-display-inline-block" :class="storageType.class"></div>
<span class="gl-mr-2 gl-font-weight-bold gl-font-sm">
......
query getStorageCounter($fullPath: ID!) {
namespace(fullPath: $fullPath) {
id
storageSizeLimit
rootStorageStatistics {
storageSize
repositorySize
......
---
title: Render storage graph when there is a limit set
merge_request: 34931
author:
type: changed
import { shallowMount } from '@vue/test-utils';
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 { projects, withRootStorageStatistics } from '../data';
import { numberToHumanSize } from '~/lib/utils/number_utils';
describe('Storage counter app', () => {
let wrapper;
const findTotalUsage = () => wrapper.find("[data-testid='total-usage']");
function createComponent(loading = false) {
const $apollo = {
queries: {
......@@ -15,7 +18,7 @@ describe('Storage counter app', () => {
},
};
wrapper = shallowMount(StorageApp, {
wrapper = mount(StorageApp, {
propsData: { namespacePath: 'h5bp', helpPagePath: 'help' },
mocks: { $apollo },
});
......@@ -29,51 +32,59 @@ describe('Storage counter app', () => {
wrapper.destroy();
});
it('renders the 2 projects', done => {
it('renders the 2 projects', async () => {
wrapper.setData({
namespace: projects,
});
wrapper.vm
.$nextTick()
.then(() => {
await wrapper.vm.$nextTick();
expect(wrapper.findAll(Project)).toHaveLength(2);
})
.then(done)
.catch(done.fail);
});
describe('limit', () => {
it('when limit is set it renders limit information', async () => {
wrapper.setData({
namespace: projects,
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(numberToHumanSize(projects.limit));
});
it('when limit is 0 it does not render limit information', async () => {
wrapper.setData({
namespace: { ...projects, limit: 0 },
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(numberToHumanSize(0));
});
});
describe('with rootStorageStatistics information', () => {
it('renders total usage', done => {
it('renders total usage', async () => {
wrapper.setData({
namespace: withRootStorageStatistics,
});
wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find("[data-testid='total-usage']").text()).toContain(
withRootStorageStatistics.totalUsage,
);
})
.then(done)
.catch(done.fail);
await wrapper.vm.$nextTick();
expect(findTotalUsage().text()).toContain(withRootStorageStatistics.totalUsage);
});
});
describe('without rootStorageStatistics information', () => {
it('renders N/A', done => {
it('renders N/A', async () => {
wrapper.setData({
namespace: projects,
});
wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.find("[data-testid='total-usage']").text()).toContain('N/A');
})
.then(done)
.catch(done.fail);
await wrapper.vm.$nextTick();
expect(findTotalUsage().text()).toContain('N/A');
});
});
});
......@@ -2,26 +2,36 @@ import { shallowMount } from '@vue/test-utils';
import UsageGraph from 'ee/storage_counter/components/usage_graph.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
const data = {
wikiSize: 5000,
repositorySize: 4000,
packagesSize: 3000,
lfsObjectsSize: 2000,
buildArtifactsSize: 1000,
storageSize: 15000,
};
let data;
let wrapper;
function mountComponent(rootStorageStatistics) {
function mountComponent({ rootStorageStatistics, limit }) {
wrapper = shallowMount(UsageGraph, {
propsData: {
rootStorageStatistics,
limit,
},
});
}
function findStorageTypeUsagesSerialized() {
return wrapper
.findAll('[data-testid="storage-type-usage"]')
.wrappers.map(wp => wp.element.style.width);
}
describe('Storage Counter usage graph component', () => {
beforeEach(() => {
data = {
rootStorageStatistics: {
wikiSize: 5000,
repositorySize: 4000,
packagesSize: 3000,
lfsObjectsSize: 2000,
buildArtifactsSize: 1000,
storageSize: 15000,
},
limit: 2000,
};
mountComponent(data);
});
......@@ -30,26 +40,34 @@ describe('Storage Counter usage graph component', () => {
});
it('renders the legend in order', () => {
const types = wrapper.findAll('[data-testid="storage-type"]');
const types = wrapper.findAll('[data-testid="storage-type-legend"]');
expect(types.at(0).text()).toContain('Wikis');
expect(types.at(1).text()).toContain('Repositories');
expect(types.at(2).text()).toContain('Packages');
expect(types.at(3).text()).toContain('LFS Objects');
expect(types.at(4).text()).toContain('Build Artifacts');
});
const {
buildArtifactsSize,
lfsObjectsSize,
packagesSize,
repositorySize,
wikiSize,
} = data.rootStorageStatistics;
it('renders formatted data in the legend', () => {
expect(wrapper.text()).toContain(numberToHumanSize(data.buildArtifactsSize));
expect(wrapper.text()).toContain(numberToHumanSize(data.lfsObjectsSize));
expect(wrapper.text()).toContain(numberToHumanSize(data.packagesSize));
expect(wrapper.text()).toContain(numberToHumanSize(data.repositorySize));
expect(wrapper.text()).toContain(numberToHumanSize(data.wikiSize));
expect(types.at(0).text()).toMatchInterpolatedText(`Wikis ${numberToHumanSize(wikiSize)}`);
expect(types.at(1).text()).toMatchInterpolatedText(
`Repositories ${numberToHumanSize(repositorySize)}`,
);
expect(types.at(2).text()).toMatchInterpolatedText(
`Packages ${numberToHumanSize(packagesSize)}`,
);
expect(types.at(3).text()).toMatchInterpolatedText(
`LFS Objects ${numberToHumanSize(lfsObjectsSize)}`,
);
expect(types.at(4).text()).toMatchInterpolatedText(
`Build Artifacts ${numberToHumanSize(buildArtifactsSize)}`,
);
});
describe('when storage type is not used', () => {
beforeEach(() => {
data.wikiSize = 0;
data.rootStorageStatistics.wikiSize = 0;
mountComponent(data);
});
......@@ -60,11 +78,34 @@ describe('Storage Counter usage graph component', () => {
describe('when there is no storage usage', () => {
beforeEach(() => {
mountComponent({ storageSize: 0 });
data.rootStorageStatistics.storageSize = 0;
mountComponent(data);
});
it('it does not render', () => {
expect(wrapper.html()).toEqual('');
});
});
describe('when limit is 0', () => {
beforeEach(() => {
data.limit = 0;
mountComponent(data);
});
it('sets correct width values', () => {
expect(findStorageTypeUsagesSerialized()).toStrictEqual(['33%', '27%', '20%', '13%', '7%']);
});
});
describe('when storage exceeds limit', () => {
beforeEach(() => {
data.limit = data.rootStorageStatistics.storageSize - 1;
mountComponent(data);
});
it('it does render correclty', () => {
expect(findStorageTypeUsagesSerialized()).toStrictEqual(['33%', '27%', '20%', '13%', '7%']);
});
});
});
export const projects = {
totalUsage: 'N/A',
limit: 10000000,
projects: [
{
id: '24',
......
......@@ -24644,9 +24644,6 @@ msgstr ""
msgid "UsageQuota|Storage"
msgstr ""
msgid "UsageQuota|Storage usage:"
msgstr ""
msgid "UsageQuota|This namespace has no projects which use shared runners"
msgstr ""
......@@ -24677,6 +24674,12 @@ msgstr ""
msgid "UsageQuota|Wikis"
msgstr ""
msgid "UsageQuota|You used: %{usage} %{limit}"
msgstr ""
msgid "UsageQuota|out of %{formattedLimit} of your namespace storage"
msgstr ""
msgid "Use %{code_start}::%{code_end} to create a %{link_start}scoped label set%{link_end} (eg. %{code_start}priority::1%{code_end})"
msgstr ""
......
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