Commit 4cfd98de authored by Phil Hughes's avatar Phil Hughes

Merge branch '59232-add-storage-counter' into 'master'

Adds Storage Counter

See merge request gitlab-org/gitlab-ee!13294
parents 7db21ae7 e0191c18
......@@ -51,6 +51,7 @@ export default class LinkedTabs {
this.defaultAction = this.options.defaultAction;
this.action = this.options.action || this.defaultAction;
this.hashedTabs = this.options.hashedTabs || false;
if (this.action === 'show') {
this.action = this.defaultAction;
......@@ -58,6 +59,10 @@ export default class LinkedTabs {
this.currentLocation = window.location;
if (this.hashedTabs) {
this.action = this.currentLocation.hash || this.action;
}
const tabSelector = `${this.options.parentEl} a[data-toggle="tab"]`;
// since this is a custom event we need jQuery :(
......@@ -91,7 +96,9 @@ export default class LinkedTabs {
copySource.replace(/\/+$/, '');
const newState = `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
const newState = this.hashedTabs
? copySource
: `${copySource}${this.currentLocation.search}${this.currentLocation.hash}`;
window.history.replaceState(
{
......
......@@ -100,3 +100,9 @@ export function numberToHumanSize(size) {
* @returns {Float} The summed value
*/
export const sum = (a = 0, b = 0) => a + b;
/**
* Checks if the provided number is odd
* @param {Int} number
*/
export const isOdd = (number = 0) => number % 2;
import storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
document.addEventListener('DOMContentLoaded', () => {
if (document.querySelector('#js-storage-counter-app')) {
storageCounter();
// eslint-disable-next-line no-new
new LinkedTabs({
defaultAction: '#pipelines-quota-tab',
parentEl: '.js-storage-tabs',
hashedTabs: true,
});
}
});
<script>
import Project from './project.vue';
import query from '../queries/storage.graphql';
export default {
components: {
Project,
},
props: {
namespacePath: {
type: String,
required: true,
},
},
apollo: {
namespace: {
query,
variables() {
return {
fullPath: this.namespacePath,
};
},
update: data => ({
projects: data.namespace.projects.edges.map(({ node }) => node),
}),
},
},
data() {
return {
namespace: {},
};
},
};
</script>
<template>
<div class="ci-table" role="grid">
<div
class="gl-responsive-table-row table-row-header bg-gray-light pl-2 border-top mt-3 lh-100"
role="row"
>
<div class="table-section section-70 font-weight-bold" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-30 font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
</div>
<project v-for="project in namespace.projects" :key="project.id" :project="project" />
</div>
</template>
<script>
import { GlButton, GlLink } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { numberToHumanSize, isOdd } from '~/lib/utils/number_utils';
import { s__ } from '~/locale';
import StorageRow from './storage_row.vue';
export default {
components: {
Icon,
GlButton,
GlLink,
ProjectAvatar,
StorageRow,
},
props: {
project: {
required: true,
type: Object,
},
},
data() {
return {
isOpen: false,
};
},
computed: {
projectAvatar() {
const { name, id, avatarUrl, webUrl } = this.project;
return {
name,
id: Number(id),
avatar_url: avatarUrl,
path: webUrl,
};
},
name() {
return this.project.nameWithNamespace;
},
storageSize() {
return numberToHumanSize(this.project.statistics.storageSize);
},
iconName() {
return this.isOpen ? 'angle-down' : 'angle-right';
},
statistics() {
const statisticsCopy = Object.assign({}, this.project.statistics);
delete statisticsCopy.storageSize;
// eslint-disable-next-line no-underscore-dangle
delete statisticsCopy.__typename;
return statisticsCopy;
},
},
methods: {
toggleProject() {
this.isOpen = !this.isOpen;
},
getFormattedName(name) {
return this.$options.i18nStatisticsMap[name];
},
isOdd(num) {
return isOdd(num);
},
/**
* Some values can be `nil`
* for those, we send 0 instead
*/
getValue(val) {
return val || 0;
},
},
i18nStatisticsMap: {
commitCount: s__('UsageQuota|Commit count'),
repositorySize: s__('UsageQuota|Repository'),
lfsObjectsSize: s__('UsageQuota|LFS Storage'),
buildArtifactsSize: s__('UsageQuota|Artifacts'),
packagesSize: s__('UsageQuota|Packages'),
wikiSize: s__('UsageQuota|Wiki'),
},
};
</script>
<template>
<div>
<div class="gl-responsive-table-row border-bottom" role="row">
<div class="table-section section-wrap section-70 text-truncate" role="gridcell">
<div class="table-mobile-header font-weight-bold" role="rowheader">{{ __('Project') }}</div>
<div class="table-mobile-content">
<gl-button
class="btn-transparent float-left p-0 mr-2"
:aria-label="__('Toggle project')"
@click="toggleProject"
>
<icon :name="iconName" class="folder-icon" />
</gl-button>
<project-avatar :project="projectAvatar" :size="20" />
<gl-link :href="project.webUrl" class="font-weight-bold">{{ name }}</gl-link>
</div>
</div>
<div class="table-section section-wrap section-30 text-truncate" role="gridcell">
<div class="table-mobile-header font-weight-bold" role="rowheader">{{ __('Usage') }}</div>
<div class="table-mobile-content">{{ storageSize }}</div>
</div>
</div>
<template v-if="isOpen">
<storage-row
v-for="(value, statisticsName, index) in statistics"
:key="index"
:name="getFormattedName(statisticsName)"
:value="getValue(value)"
:class="{ 'bg-gray-light': isOdd(index) }"
/>
</template>
</div>
</template>
<script>
import { numberToHumanSize } from '~/lib/utils/number_utils';
export default {
props: {
name: {
type: String,
required: true,
},
value: {
type: Number,
required: true,
},
},
computed: {
formattedValue() {
return numberToHumanSize(this.value);
},
},
};
</script>
<template>
<div class="gl-responsive-table-row lh-100" role="row">
<div class="table-section section-wrap section-70 text-truncate pl-2 ml-3" role="gridcell">
<div class="table-mobile-header" role="rowheader"></div>
<div class="table-mobile-content ml-1">{{ name }}</div>
</div>
<div class="table-section section-wrap section-30 text-truncate" role="gridcell">
<div class="table-mobile-header" role="rowheader"></div>
<div class="table-mobile-content">{{ formattedValue }}</div>
</div>
</div>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import App from './components/app.vue';
Vue.use(VueApollo);
export default () => {
const el = document.getElementById('js-storage-counter-app');
const { namespacePath } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(h) {
return h(App, {
props: {
namespacePath,
},
});
},
});
};
query getStorageCounter($fullPath: ID!) {
namespace(fullPath: $fullPath) {
id
projects(includeSubgroups: true) {
edges {
node {
id
fullPath
nameWithNamespace
avatarUrl
webUrl
name
statistics {
commitCount
storageSize
repositorySize
lfsObjectsSize
buildArtifactsSize
packagesSize
wikiSize
}
}
}
}
}
}
......@@ -22,7 +22,6 @@
%span
Audit Events
- if @group.shared_runners_enabled? && @group.shared_runners_minutes_limit_enabled?
= nav_link(path: 'usage_quota#index') do
= link_to group_usage_quotas_path(@group), title: s_('UsageQuota|Usage Quotas') do
%span
......
......@@ -8,14 +8,22 @@
= s_('UsageQuota|Usage of group resources across the projects in the %{strong_start}%{group_name}%{strong_end} group').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, group_name: @group.name }
.top-area.scrolling-tabs-container.inner-page-scroll-tabs
%ul.nav-links.scrolling-tabs.separator{ role: 'tablist' }
%ul.nav.nav-tabs.nav-links.scrolling-tabs.separator.js-storage-tabs{ role: 'tablist' }
%li.nav-item
%a.nav-link.active#pipelines-quota{ href: 'pipelines-quota-tab', data: { toggle: 'tab' }, 'aria-controls': 'pipelines-quota-tab', 'aria-selected': true }
%a.nav-link#pipelines-quota{ data: { toggle: "tab", action: '#pipelines-quota-tab' }, href: '#pipelines-quota-tab', 'aria-controls': '#pipelines-quota-tab', 'aria-selected': true }
= s_('UsageQuota|Pipelines')
- if Gitlab::Graphql.enabled?
%li.nav-item
%a.nav-link#storage-quota{ data: { toggle: "tab", action: '#storage-quota-tab' }, href: '#storage-quota-tab', 'aria-controls': '#storage-quota-tab', 'aria-selected': false }
= s_('UsageQuota|Storage')
.nav-controls
= link_to s_('UsageQuota|Buy additional minutes'), EE::SUBSCRIPTIONS_PLANS_URL, target: '_blank', class: 'btn btn-inverted btn-success float-right'
.tab-content
.tab-pane.show.active#pipelines-quota-tab{ role: 'tabpanel' }
.tab-pane#pipelines-quota-tab
= render "namespaces/pipelines_quota/list",
locals: { namespace: @group, projects: @projects }
.tab-pane#storage-quota-tab
- if Gitlab::Graphql.enabled?
#js-storage-counter-app{ data: { namespace_path: @group.full_path } }
......@@ -19,6 +19,8 @@
.col-sm-6.right
- if namespace.shared_runners_minutes_limit_enabled?
#{namespace_shared_runner_limits_percent_used(namespace)}% used
- elsif !namespace.shared_runners_enabled?
0% used
- else
= s_('UsageQuota|Unlimited')
......@@ -35,18 +37,26 @@
= _('Minutes')
%tbody
- projects.each do |project|
%tr
%td
.avatar-container.s20.d-none.d-sm-block
= project_icon(project, alt: '', class: 'avatar project-avatar s20')
%strong= link_to project.full_name, project
%td
= project.shared_runners_minutes
- if projects.blank?
- if !namespace.shared_runners_enabled?
%tr
%td{ colspan: 2 }
.nothing-here-block
= s_('UsageQuota|This group has no projects which use shared runners')
- runners_doc_path = help_page_path('ci/runners/README.html')
- help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: runners_doc_path }
= s_('UsageQuota|%{help_link_start}Shared runners%{help_link_end} are disabled, so there are no limits set on pipeline usage').html_safe % { help_link_start: help_link_start, help_link_end: '</a>'.html_safe }
- else
- projects.each do |project|
%tr
%td
.avatar-container.s20.d-none.d-sm-block
= project_icon(project, alt: '', class: 'avatar project-avatar s20')
%strong= link_to project.full_name, project
%td
= project.shared_runners_minutes
- if projects.blank?
%tr
%td{ colspan: 2 }
.nothing-here-block
= s_('UsageQuota|This namespace has no projects which use shared runners')
= paginate projects, theme: "gitlab"
---
title: Adds Storage Counter
merge_request: 13294
author:
type: added
......@@ -35,22 +35,22 @@ describe 'Groups > Usage Quotas' do
let(:group) { create(:group, :with_not_used_build_minutes_limit) }
let!(:project) { create(:project, namespace: group, shared_runners_enabled: false) }
it 'is not linked within the group settings dropdown' do
it 'is linked within the group settings dropdown' do
visit edit_group_path(group)
expect(page).not_to have_link('Usage Quotas')
expect(page).to have_link('Usage Quotas')
end
it 'shows correct group quota info' do
visit_pipeline_quota_page
page.within('.pipeline-quota') do
expect(page).to have_content("300 / Unlimited minutes")
expect(page).to have_content("0%")
expect(page).to have_selector('.bg-success')
end
page.within('.pipeline-project-metrics') do
expect(page).to have_content('This group has no projects which use shared runners')
expect(page).to have_content('Shared runners are disabled, so there are no limits set on pipeline usage')
end
end
end
......
......@@ -23,14 +23,14 @@ describe 'Profile > Pipeline Quota' do
describe 'shared runners use' do
where(:shared_runners_enabled, :used, :quota, :usage_class, :usage_text) do
false | 300 | 500 | 'success' | '300 / Unlimited minutes Unlimited'
false | 300 | 500 | 'success' | '300 / Unlimited minutes 0% used'
true | 300 | nil | 'success' | '300 / Unlimited minutes Unlimited'
true | 300 | 500 | 'success' | '300 / 500 minutes 60% used'
true | 1000 | 500 | 'danger' | '1000 / 500 minutes 200% used'
end
with_them do
let(:no_shared_runners_text) { 'This group has no projects which use shared runners' }
let(:no_shared_runners_text) { 'Shared runners are disabled, so there are no limits set on pipeline usage' }
before do
project.update!(shared_runners_enabled: shared_runners_enabled)
......
import { shallowMount } from '@vue/test-utils';
import StorageApp from 'ee/storage_counter/components/app.vue';
import Project from 'ee/storage_counter/components/project.vue';
import { projects } from '../data';
describe('Storage counter app', () => {
let wrapper;
function createComponent(loading = false) {
const $apollo = {
queries: {
namespace: {
loading,
},
},
};
wrapper = shallowMount(StorageApp, {
propsData: { namespacePath: 'h5bp' },
mocks: { $apollo },
});
}
beforeEach(() => {
createComponent();
wrapper.setData({
namespace: projects,
});
});
it('renders the 2 projects', () => {
expect(wrapper.findAll(Project).length).toEqual(2);
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import Project from 'ee/storage_counter/components/project.vue';
import ProjectAvatar from '~/vue_shared/components/project_avatar/default.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
let wrapper;
const data = {
id: '8',
fullPath: 'h5bp/html5-boilerplate',
nameWithNamespace: 'H5bp / Html5 Boilerplate',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/html5-boilerplate',
name: 'Html5 Boilerplate',
statistics: {
commitCount: 0,
storageSize: 1293346,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
};
function factory(project) {
wrapper = shallowMount(Project, {
propsData: {
project,
},
});
}
describe('Storage Counter project component', () => {
beforeEach(() => {
factory(data);
});
it('renders project avatar', () => {
expect(wrapper.contains(ProjectAvatar)).toBe(true);
});
it('renders project name', () => {
expect(wrapper.text()).toContain(data.nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(numberToHumanSize(data.statistics.storageSize));
});
describe('toggle row', () => {
describe('on click', () => {
it('toggles isOpen', () => {
expect(wrapper.vm.isOpen).toEqual(false);
wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.isOpen).toEqual(true);
wrapper.find(GlButton).vm.$emit('click');
expect(wrapper.vm.isOpen).toEqual(false);
});
});
});
});
import { shallowMount } from '@vue/test-utils';
import StorageRow from 'ee/storage_counter/components/storage_row.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
let wrapper;
const data = {
name: 'LFS Package',
value: 1293346,
};
function factory({ name, value }) {
wrapper = shallowMount(StorageRow, {
propsData: {
name,
value,
},
});
}
describe('Storage Counter row component', () => {
beforeEach(() => {
factory(data);
});
it('renders provided name', () => {
expect(wrapper.text()).toContain(data.name);
});
it('renders formatted value', () => {
expect(wrapper.text()).toContain(numberToHumanSize(data.value));
});
});
// eslint-disable-next-line import/prefer-default-export
export const projects = {
projects: [
{
id: '24',
fullPath: 'h5bp/dummy-project',
nameWithNamespace: 'H5bp / dummy project',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/dummy-project',
name: 'dummy project',
statistics: {
commitCount: 1,
storageSize: 41943,
repositorySize: 41943,
lfsObjectsSize: 0,
buildArtifactsSize: 0,
packagesSize: 0,
},
},
{
id: '8',
fullPath: 'h5bp/html5-boilerplate',
nameWithNamespace: 'H5bp / Html5 Boilerplate',
avatarUrl: null,
webUrl: 'http://localhost:3001/h5bp/html5-boilerplate',
name: 'Html5 Boilerplate',
statistics: {
commitCount: 0,
storageSize: 1293346,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
},
],
};
......@@ -13861,6 +13861,9 @@ msgstr ""
msgid "Toggle navigation"
msgstr ""
msgid "Toggle project"
msgstr ""
msgid "Toggle sidebar"
msgstr ""
......@@ -14275,16 +14278,37 @@ msgstr ""
msgid "Usage statistics"
msgstr ""
msgid "UsageQuota|%{help_link_start}Shared runners%{help_link_end} are disabled, so there are no limits set on pipeline usage"
msgstr ""
msgid "UsageQuota|Artifacts"
msgstr ""
msgid "UsageQuota|Buy additional minutes"
msgstr ""
msgid "UsageQuota|Commit count"
msgstr ""
msgid "UsageQuota|Current period usage"
msgstr ""
msgid "UsageQuota|LFS Storage"
msgstr ""
msgid "UsageQuota|Packages"
msgstr ""
msgid "UsageQuota|Pipelines"
msgstr ""
msgid "UsageQuota|This group has no projects which use shared runners"
msgid "UsageQuota|Repository"
msgstr ""
msgid "UsageQuota|Storage"
msgstr ""
msgid "UsageQuota|This namespace has no projects which use shared runners"
msgstr ""
msgid "UsageQuota|Unlimited"
......@@ -14305,6 +14329,9 @@ msgstr ""
msgid "UsageQuota|Usage since"
msgstr ""
msgid "UsageQuota|Wiki"
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 ""
......
......@@ -5,6 +5,7 @@ import {
bytesToGiB,
numberToHumanSize,
sum,
isOdd,
} from '~/lib/utils/number_utils';
describe('Number Utils', () => {
......@@ -98,4 +99,14 @@ describe('Number Utils', () => {
expect([1, 2, 3, 4, 5].reduce(sum)).toEqual(15);
});
});
describe('isOdd', () => {
it('should return 0 with a even number', () => {
expect(isOdd(2)).toEqual(0);
});
it('should return 1 with a odd number', () => {
expect(isOdd(1)).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