Commit 1c8be248 authored by Ammar Alakkad's avatar Ammar Alakkad

Remove unused other_storage_counter

other_storage_counter was introduced under a feature flag that hasn't
been rolled out, the intended feature has been stopped thus the
introduced code won't be used anymore.

This MR removes unused code along its feature flag

Changelog: other
parent b7d435bc
---
name: other_storage_tab
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57121
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/325967
milestone: '13.11'
type: development
group: group::fulfillment
default_enabled: false
<script>
import {
GlLink,
GlSprintf,
GlModalDirective,
GlButton,
GlIcon,
GlKeysetPagination,
} from '@gitlab/ui';
import { parseBoolean } from '~/lib/utils/common_utils';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { PROJECTS_PER_PAGE } from '../constants';
import query from '../queries/storage.query.graphql';
import { formatUsageSize, parseGetStorageResults } from '../utils';
import ProjectsTable from './projects_table.vue';
import StorageInlineAlert from './storage_inline_alert.vue';
import TemporaryStorageIncreaseModal from './temporary_storage_increase_modal.vue';
import UsageGraph from './usage_graph.vue';
import UsageStatistics from './usage_statistics.vue';
export default {
name: 'OtherStorageCounterApp',
components: {
GlLink,
GlIcon,
GlButton,
GlSprintf,
UsageGraph,
ProjectsTable,
UsageStatistics,
StorageInlineAlert,
GlKeysetPagination,
TemporaryStorageIncreaseModal,
},
directives: {
GlModalDirective,
},
mixins: [glFeatureFlagsMixin()],
props: {
namespacePath: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
purchaseStorageUrl: {
type: String,
required: false,
default: null,
},
isTemporaryStorageIncreaseVisible: {
type: String,
required: false,
default: 'false',
},
},
apollo: {
namespace: {
query,
variables() {
return {
fullPath: this.namespacePath,
searchTerm: this.searchTerm,
withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
};
},
update: parseGetStorageResults,
result() {
this.firstFetch = false;
},
},
},
data() {
return {
namespace: {},
searchTerm: '',
firstFetch: true,
};
},
computed: {
namespaceProjects() {
return this.namespace?.projects?.data ?? [];
},
isStorageIncreaseModalVisible() {
return parseBoolean(this.isTemporaryStorageIncreaseVisible);
},
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
formattedNamespaceLimit() {
return formatUsageSize(this.namespace.limit);
},
storageStatistics() {
if (!this.namespace) {
return null;
}
return {
totalRepositorySize: this.namespace.totalRepositorySize,
actualRepositorySizeLimit: this.namespace.actualRepositorySizeLimit,
totalRepositorySizeExcess: this.namespace.totalRepositorySizeExcess,
additionalPurchasedStorageSize: this.namespace.additionalPurchasedStorageSize,
};
},
isQueryLoading() {
return this.$apollo.queries.namespace.loading;
},
pageInfo() {
return this.namespace.projects?.pageInfo ?? {};
},
shouldShowStorageInlineAlert() {
if (this.firstFetch) {
// for initial load check if the data fetch is done (isQueryLoading)
return this.isAdditionalStorageFlagEnabled && !this.isQueryLoading;
}
// for all subsequent queries the storage inline alert doesn't
// have to be re-rendered as the data from graphql will remain
// the same.
return this.isAdditionalStorageFlagEnabled;
},
showPagination() {
return Boolean(this.pageInfo?.hasPreviousPage || this.pageInfo?.hasNextPage);
},
},
methods: {
handleSearch(input) {
// if length === 0 clear the search, if length > 2 update the search term
if (input.length === 0 || input.length > 2) {
this.searchTerm = input;
}
},
fetchMoreProjects(vars) {
this.$apollo.queries.namespace.fetchMore({
variables: {
fullPath: this.namespacePath,
withExcessStorageData: this.isAdditionalStorageFlagEnabled,
first: PROJECTS_PER_PAGE,
...vars,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
},
onPrev(before) {
if (this.pageInfo?.hasPreviousPage) {
this.fetchMoreProjects({ before });
}
},
onNext(after) {
if (this.pageInfo?.hasNextPage) {
this.fetchMoreProjects({ after });
}
},
},
modalId: 'temporary-increase-storage-modal',
};
</script>
<template>
<div>
<storage-inline-alert
v-if="shouldShowStorageInlineAlert"
:contains-locked-projects="namespace.containsLockedProjects"
:repository-size-excess-project-count="namespace.repositorySizeExcessProjectCount"
:total-repository-size-excess="namespace.totalRepositorySizeExcess"
:total-repository-size="namespace.totalRepositorySize"
:additional-purchased-storage-size="namespace.additionalPurchasedStorageSize"
:actual-repository-size-limit="namespace.actualRepositorySizeLimit"
/>
<div v-if="isAdditionalStorageFlagEnabled && storageStatistics">
<usage-statistics
:root-storage-statistics="storageStatistics"
:purchase-storage-url="purchaseStorageUrl"
/>
</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">
{{ 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">{{ formattedNamespaceLimit }}</span>
</template>
</gl-sprintf>
</template>
</gl-sprintf>
<gl-link
:href="helpPagePath"
target="_blank"
:aria-label="s__('UsageQuota|Usage quotas help link')"
>
<gl-icon name="question" :size="12" />
</gl-link>
</div>
<div class="gl-w-half gl-text-right">
<gl-button
v-if="isStorageIncreaseModalVisible"
v-gl-modal-directive="$options.modalId"
category="secondary"
variant="success"
data-testid="temporary-storage-increase-button"
>{{ s__('UsageQuota|Increase storage temporarily') }}</gl-button
>
<gl-link
v-if="purchaseStorageUrl"
:href="purchaseStorageUrl"
class="btn btn-success gl-ml-2"
target="_blank"
data-testid="purchase-storage-link"
>{{ s__('UsageQuota|Purchase more storage') }}</gl-link
>
</div>
</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"
:is-loading="isQueryLoading"
:additional-purchased-storage-size="namespace.additionalPurchasedStorageSize || 0"
@search="handleSearch"
/>
<div class="gl-display-flex gl-justify-content-center gl-mt-5">
<gl-keyset-pagination v-if="showPagination" v-bind="pageInfo" @prev="onPrev" @next="onNext" />
</div>
<temporary-storage-increase-modal
v-if="isStorageIncreaseModalVisible"
:limit="formattedNamespaceLimit"
:modal-id="$options.modalId"
/>
</div>
</template>
<script>
import { GlLink, GlIcon } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { numberToHumanSize, isOdd } from '~/lib/utils/number_utils';
import { s__ } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import StorageRow from './storage_row.vue';
export default {
components: {
GlIcon,
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(getIdFromGraphQLId(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 = { ...this.project.statistics };
delete statisticsCopy.storageSize;
// eslint-disable-next-line no-underscore-dangle
delete statisticsCopy.__typename;
delete statisticsCopy.commitCount;
return statisticsCopy;
},
},
methods: {
toggleProject(e) {
const NO_EXPAND_CLS = 'js-project-link';
const targetClasses = e.target.classList;
if (targetClasses.contains(NO_EXPAND_CLS)) {
return;
}
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: {
repositorySize: s__('UsageQuota|Repository'),
lfsObjectsSize: s__('UsageQuota|LFS Storage'),
buildArtifactsSize: s__('UsageQuota|Artifacts'),
packagesSize: s__('UsageQuota|Packages'),
wikiSize: s__('UsageQuota|Wiki'),
snippetsSize: s__('UsageQuota|Snippets'),
uploadsSize: s__('UsageQuota|Uploads'),
},
};
</script>
<template>
<div>
<div
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100 gl-hover-bg-blue-50 gl-hover-border-blue-200 gl-hover-cursor-pointer"
role="row"
data-testid="projectTableRow"
@click="toggleProject"
>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-70 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Project') }}
</div>
<div class="table-mobile-content gl-display-flex gl-align-items-center">
<div class="gl-display-flex gl-mr-3 gl-align-items-center">
<gl-icon :size="10" :name="iconName" use-deprecated-sizes class="gl-mr-2" />
<gl-icon name="bookmark" />
</div>
<div>
<project-avatar :project="projectAvatar" :size="32" />
</div>
<gl-link
:href="project.webUrl"
class="js-project-link gl-font-weight-bold gl-text-gray-900!"
>{{ name }}</gl-link
>
</div>
</div>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-30 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Usage') }}
</div>
<div class="table-mobile-content gl-text-gray-900">{{ 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="{ 'gl-bg-gray-10': isOdd(index) }"
/>
</template>
</div>
</template>
<script>
import { GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { s__, sprintf } from '~/locale';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import { ALERT_THRESHOLD, ERROR_THRESHOLD, WARNING_THRESHOLD } from '../constants';
import { formatUsageSize, usageRatioToThresholdLevel } from '../utils';
export default {
i18n: {
warningWithNoPurchasedStorageText: s__(
'UsageQuota|This project is near the free %{actualRepositorySizeLimit} limit and at risk of being locked.',
),
lockedWithNoPurchasedStorageText: s__(
'UsageQuota|This project is locked because it is using %{actualRepositorySizeLimit} of free storage and there is no purchased storage available.',
),
warningWithPurchasedStorageText: s__(
'UsageQuota|This project is at risk of being locked because purchased storage is running low.',
),
lockedWithPurchasedStorageText: s__(
'UsageQuota|This project is locked because it used %{actualRepositorySizeLimit} of free storage and all the purchased storage.',
),
},
components: {
GlIcon,
GlLink,
ProjectAvatar,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
project: {
required: true,
type: Object,
},
additionalPurchasedStorageSize: {
type: Number,
required: true,
},
},
computed: {
projectAvatar() {
const { name, id, avatarUrl, webUrl } = this.project;
return {
name,
id: Number(getIdFromGraphQLId(id)),
avatar_url: avatarUrl,
path: webUrl,
};
},
name() {
return this.project.nameWithNamespace;
},
hasPurchasedStorage() {
return this.additionalPurchasedStorageSize > 0;
},
storageSize() {
return formatUsageSize(this.project.totalCalculatedUsedStorage);
},
excessStorageSize() {
return formatUsageSize(this.project.repositorySizeExcess);
},
excessStorageRatio() {
return this.project.totalCalculatedUsedStorage / this.project.totalCalculatedStorageLimit;
},
thresholdLevel() {
return usageRatioToThresholdLevel(this.excessStorageRatio);
},
status() {
const i18nTextOpts = {
actualRepositorySizeLimit: formatUsageSize(this.project.actualRepositorySizeLimit),
};
if (this.thresholdLevel === ERROR_THRESHOLD) {
const tooltipText = this.hasPurchasedStorage
? this.$options.i18n.lockedWithPurchasedStorageText
: this.$options.i18n.lockedWithNoPurchasedStorageText;
return {
bgColor: { 'gl-bg-red-50': true },
iconClass: { 'gl-text-red-500': true },
linkClass: 'gl-text-red-500!',
tooltipText: sprintf(tooltipText, i18nTextOpts),
};
} else if (
this.thresholdLevel === WARNING_THRESHOLD ||
this.thresholdLevel === ALERT_THRESHOLD
) {
const tooltipText = this.hasPurchasedStorage
? this.$options.i18n.warningWithPurchasedStorageText
: this.$options.i18n.warningWithNoPurchasedStorageText;
return {
bgColor: { 'gl-bg-orange-50': true },
iconClass: 'gl-text-orange-500',
tooltipText: sprintf(tooltipText, i18nTextOpts),
};
}
return {};
},
},
};
</script>
<template>
<div
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100"
:class="status.bgColor"
role="row"
data-testid="projectTableRow"
>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-50 gl-text-truncate gl-pr-5"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Project') }}
</div>
<div class="table-mobile-content gl-display-flex gl-align-items-center">
<div class="gl-display-flex gl-mr-3 gl-ml-5 gl-align-items-center">
<gl-icon name="bookmark" />
</div>
<div>
<project-avatar :project="projectAvatar" :size="32" />
</div>
<div v-if="status.iconClass">
<gl-icon
v-gl-tooltip="{ title: status.tooltipText }"
name="status_warning"
class="gl-mr-3"
:class="status.iconClass"
/>
</div>
<gl-link
:href="project.webUrl"
class="gl-font-weight-bold gl-text-gray-900!"
:class="status.linkClass"
>{{ name }}</gl-link
>
</div>
</div>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-15 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Usage') }}
</div>
<div class="table-mobile-content gl-text-gray-900">{{ storageSize }}</div>
</div>
<div
class="table-section gl-white-space-normal! gl-sm-flex-wrap section-15 gl-text-truncate"
role="gridcell"
>
<div class="table-mobile-header gl-font-weight-bold" role="rowheader">
{{ __('Excess storage') }}
</div>
<div class="table-mobile-content gl-text-gray-900">{{ excessStorageSize }}</div>
</div>
</div>
</template>
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
import { SKELETON_LOADER_ROWS } from '../constants';
export default {
name: 'ProjectsSkeletonLoader',
components: { GlSkeletonLoader },
SKELETON_LOADER_ROWS,
};
</script>
<template>
<div class="gl-border-b-solid gl-border-b-1 gl-border-gray-100">
<div class="gl-flex-direction-column gl-md-display-none" data-testid="mobile-loader">
<div
v-for="index in $options.SKELETON_LOADER_ROWS.mobile"
:key="index"
class="gl-responsive-table-row gl-border-solid gl-border-b-1 gl-pt-3 gl-pb-3 gl-border-b-gray-100"
>
<gl-skeleton-loader :width="500" :height="172">
<rect width="480" height="20" x="10" y="15" rx="4" />
<rect width="480" height="20" x="10" y="80" rx="4" />
<rect width="480" height="20" x="10" y="145" rx="4" />
</gl-skeleton-loader>
</div>
</div>
<div
class="gl-display-none gl-md-display-flex gl-flex-direction-column"
data-testid="desktop-loader"
>
<gl-skeleton-loader
v-for="index in $options.SKELETON_LOADER_ROWS.desktop"
:key="index"
:width="1000"
:height="39"
>
<rect rx="4" width="320" height="8" x="0" y="18" />
<rect rx="4" width="60" height="8" x="500" y="18" />
<rect rx="4" width="60" height="8" x="750" y="18" />
</gl-skeleton-loader>
</div>
</div>
</template>
<script>
import { GlSearchBoxByType } from '@gitlab/ui';
import { SEARCH_DEBOUNCE_MS } from '~/ref/constants';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Project from './project.vue';
import ProjectWithExcessStorage from './project_with_excess_storage.vue';
import ProjectsSkeletonLoader from './projects_skeleton_loader.vue';
export default {
components: {
Project,
ProjectsSkeletonLoader,
ProjectWithExcessStorage,
GlSearchBoxByType,
},
mixins: [glFeatureFlagsMixin()],
props: {
projects: {
type: Array,
required: true,
},
additionalPurchasedStorageSize: {
type: Number,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
isAdditionalStorageFlagEnabled() {
return this.glFeatures.additionalRepoStorageByNamespace;
},
projectRowComponent() {
if (this.isAdditionalStorageFlagEnabled) {
return ProjectWithExcessStorage;
}
return Project;
},
},
searchDebounceValue: SEARCH_DEBOUNCE_MS,
};
</script>
<template>
<div>
<div
class="gl-responsive-table-row table-row-header gl-border-t-solid gl-border-t-1 gl-border-gray-100 gl-mt-5 gl-line-height-normal gl-text-black-normal gl-font-base"
role="row"
>
<template v-if="isAdditionalStorageFlagEnabled">
<div class="table-section section-50 gl-font-weight-bold gl-pl-5" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-15 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
<div class="table-section section-15 gl-font-weight-bold" role="columnheader">
{{ __('Excess storage') }}
</div>
<div class="table-section section-20 gl-font-weight-bold gl-pl-6" role="columnheader">
<gl-search-box-by-type
:placeholder="__('Search by name')"
:debounce="$options.searchDebounceValue"
@input="(input) => this.$emit('search', input)"
/>
</div>
</template>
<template v-else>
<div class="table-section section-70 gl-font-weight-bold" role="columnheader">
{{ __('Project') }}
</div>
<div class="table-section section-30 gl-font-weight-bold" role="columnheader">
{{ __('Usage') }}
</div>
</template>
</div>
<projects-skeleton-loader v-if="isAdditionalStorageFlagEnabled && isLoading" />
<template v-else>
<component
:is="projectRowComponent"
v-for="project in projects"
:key="project.id"
:project="project"
:additional-purchased-storage-size="additionalPurchasedStorageSize"
/>
</template>
</div>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { n__, s__, sprintf } from '~/locale';
import { ALERT_THRESHOLD, ERROR_THRESHOLD, WARNING_THRESHOLD } from '../constants';
import { formatUsageSize, usageRatioToThresholdLevel } from '../utils';
export default {
i18n: {
lockedWithNoPurchasedStorageTitle: s__('UsageQuota|This namespace contains locked projects'),
lockedWithNoPurchasedStorageText: s__(
'UsageQuota|You have reached the free storage limit of %{actualRepositorySizeLimit} on %{projectsLockedText}. To unlock them, please purchase additional storage.',
),
storageUsageText: s__('UsageQuota|%{percentageLeft} of purchased storage is available'),
lockedWithPurchaseText: s__(
'UsageQuota|You have consumed all of your additional storage, please purchase more to unlock your projects over the free %{actualRepositorySizeLimit} limit.',
),
warningWithPurchaseText: s__(
'UsageQuota|Your purchased storage is running low. To avoid locked projects, please purchase more storage.',
),
infoWithPurchaseText: s__(
'UsageQuota|When you purchase additional storage, we automatically unlock projects that were locked when you reached the %{actualRepositorySizeLimit} limit.',
),
},
components: {
GlAlert,
},
props: {
containsLockedProjects: {
type: Boolean,
required: true,
},
repositorySizeExcessProjectCount: {
type: Number,
required: true,
},
totalRepositorySizeExcess: {
type: Number,
required: true,
},
totalRepositorySize: {
type: Number,
required: true,
},
additionalPurchasedStorageSize: {
type: Number,
required: true,
},
actualRepositorySizeLimit: {
type: Number,
required: true,
},
},
computed: {
shouldShowAlert() {
return this.hasPurchasedStorage() || this.containsLockedProjects;
},
alertText() {
return this.hasPurchasedStorage()
? this.hasPurchasedStorageText()
: this.hasNotPurchasedStorageText();
},
alertTitle() {
if (!this.hasPurchasedStorage() && this.containsLockedProjects) {
return this.$options.i18n.lockedWithNoPurchasedStorageTitle;
}
return sprintf(this.$options.i18n.storageUsageText, {
percentageLeft: `${this.excessStoragePercentageLeft}%`,
});
},
excessStorageRatio() {
return this.totalRepositorySizeExcess / this.additionalPurchasedStorageSize;
},
excessStoragePercentageUsed() {
return (this.excessStorageRatio * 100).toFixed(0);
},
excessStoragePercentageLeft() {
return Math.max(0, 100 - this.excessStoragePercentageUsed);
},
thresholdLevel() {
return usageRatioToThresholdLevel(this.excessStorageRatio);
},
thresholdLevelToAlertVariant() {
if (this.thresholdLevel === ERROR_THRESHOLD || this.thresholdLevel === ALERT_THRESHOLD) {
return 'danger';
} else if (this.thresholdLevel === WARNING_THRESHOLD) {
return 'warning';
}
return 'info';
},
projectsLockedText() {
if (this.repositorySizeExcessProjectCount === 0) {
return '';
}
return `${this.repositorySizeExcessProjectCount} ${n__(
'project',
'projects',
this.repositorySizeExcessProjectCount,
)}`;
},
},
methods: {
hasPurchasedStorage() {
return this.additionalPurchasedStorageSize > 0;
},
formatSize(size) {
return formatUsageSize(size);
},
hasPurchasedStorageText() {
if (this.thresholdLevel === ERROR_THRESHOLD) {
return sprintf(this.$options.i18n.lockedWithPurchaseText, {
actualRepositorySizeLimit: this.formatSize(this.actualRepositorySizeLimit),
});
} else if (
this.thresholdLevel === WARNING_THRESHOLD ||
this.thresholdLevel === ALERT_THRESHOLD
) {
return this.$options.i18n.warningWithPurchaseText;
}
return sprintf(this.$options.i18n.infoWithPurchaseText, {
actualRepositorySizeLimit: this.formatSize(this.actualRepositorySizeLimit),
});
},
hasNotPurchasedStorageText() {
if (this.thresholdLevel === ERROR_THRESHOLD) {
return sprintf(this.$options.i18n.lockedWithNoPurchasedStorageText, {
actualRepositorySizeLimit: this.formatSize(this.actualRepositorySizeLimit),
projectsLockedText: this.projectsLockedText,
});
}
return '';
},
},
};
</script>
<template>
<gl-alert
v-if="shouldShowAlert"
class="gl-mt-5"
:variant="thresholdLevelToAlertVariant"
:dismissible="false"
:title="alertTitle"
>
{{ alertText }}
</gl-alert>
</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>
<script>
import { GlModal, GlSprintf } from '@gitlab/ui';
import { s__, __ } from '~/locale';
export default {
components: {
GlModal,
GlSprintf,
},
props: {
limit: {
type: String,
required: true,
},
modalId: {
type: String,
required: true,
},
},
modalBody: s__(
"TemporaryStorage|GitLab allows you a %{strongStart}free, one-time storage increase%{strongEnd}. For 30 days your storage will be unlimited. This gives you time to reduce your storage usage. After 30 days, your original storage limit of %{limit} applies. If you are at maximum storage capacity, your account will be read-only. To continue using GitLab you'll have to purchase additional storage or decrease storage usage.",
),
modalTitle: s__('TemporaryStorage|Temporarily increase storage now?'),
okTitle: s__('TemporaryStorage|Increase storage temporarily'),
cancelTitle: __('Cancel'),
};
</script>
<template>
<gl-modal
size="sm"
ok-variant="success"
:title="$options.modalTitle"
:ok-title="$options.okTitle"
:cancel-title="$options.cancelTitle"
:modal-id="modalId"
>
<gl-sprintf :message="$options.modalBody">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
<template #limit>{{ limit }}</template>
</gl-sprintf>
</gl-modal>
</template>
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import { s__ } from '~/locale';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
rootStorageStatistics: {
required: true,
type: Object,
},
limit: {
required: true,
type: Number,
},
},
computed: {
storageTypes() {
const {
buildArtifactsSize,
pipelineArtifactsSize,
lfsObjectsSize,
packagesSize,
repositorySize,
storageSize,
wikiSize,
snippetsSize,
uploadsSize,
} = this.rootStorageStatistics;
const artifactsSize = buildArtifactsSize + pipelineArtifactsSize;
if (storageSize === 0) {
return null;
}
return [
{
name: s__('UsageQuota|Repositories'),
style: this.usageStyle(this.barRatio(repositorySize)),
class: 'gl-bg-data-viz-blue-500',
size: repositorySize,
},
{
name: s__('UsageQuota|LFS Objects'),
style: this.usageStyle(this.barRatio(lfsObjectsSize)),
class: 'gl-bg-data-viz-orange-600',
size: lfsObjectsSize,
},
{
name: s__('UsageQuota|Packages'),
style: this.usageStyle(this.barRatio(packagesSize)),
class: 'gl-bg-data-viz-aqua-500',
size: packagesSize,
},
{
name: s__('UsageQuota|Artifacts'),
style: this.usageStyle(this.barRatio(artifactsSize)),
class: 'gl-bg-data-viz-green-600',
size: artifactsSize,
tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'),
},
{
name: s__('UsageQuota|Wikis'),
style: this.usageStyle(this.barRatio(wikiSize)),
class: 'gl-bg-data-viz-magenta-500',
size: wikiSize,
},
{
name: s__('UsageQuota|Snippets'),
style: this.usageStyle(this.barRatio(snippetsSize)),
class: 'gl-bg-data-viz-orange-800',
size: snippetsSize,
},
{
name: s__('UsageQuota|Uploads'),
style: this.usageStyle(this.barRatio(uploadsSize)),
class: 'gl-bg-data-viz-aqua-700',
size: uploadsSize,
},
]
.filter((data) => data.size !== 0)
.sort((a, b) => b.size - a.size);
},
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
usageStyle(ratio) {
return { flex: ratio };
},
barRatio(size) {
let max = this.rootStorageStatistics.storageSize;
if (this.limit !== 0 && max <= this.limit) {
max = this.limit;
}
return size / max;
},
},
};
</script>
<template>
<div v-if="storageTypes" class="gl-display-flex gl-flex-direction-column w-100">
<div class="gl-h-6 gl-my-5 gl-bg-gray-50 gl-rounded-base gl-display-flex">
<div
v-for="storageType in storageTypes"
:key="storageType.name"
class="storage-type-usage gl-h-full gl-display-inline-block"
:class="storageType.class"
:style="storageType.style"
data-testid="storage-type-usage"
></div>
</div>
<div class="row py-0">
<div
v-for="storageType in storageTypes"
:key="storageType.name"
class="col-md-auto gl-display-flex gl-align-items-center"
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">
{{ storageType.name }}
</span>
<span class="gl-text-gray-500 gl-font-sm">
{{ formatSize(storageType.size) }}
</span>
<span
v-if="storageType.tooltip"
v-gl-tooltip
:title="storageType.tooltip"
:aria-label="storageType.tooltip"
class="gl-ml-2"
>
<gl-icon name="question" :size="12" />
</span>
</div>
</div>
</div>
</template>
<script>
import { GlButton } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { s__ } from '~/locale';
import { formatUsageSize } from '../utils';
import UsageStatisticsCard from './usage_statistics_card.vue';
export default {
components: {
GlButton,
UsageStatisticsCard,
},
props: {
rootStorageStatistics: {
required: true,
type: Object,
},
purchaseStorageUrl: {
required: false,
type: String,
default: '',
},
},
computed: {
formattedActualRepoSizeLimit() {
return formatUsageSize(this.rootStorageStatistics.actualRepositorySizeLimit);
},
totalUsage() {
return {
usage: this.formatSizeAndSplit(this.rootStorageStatistics.totalRepositorySize),
description: s__('UsageQuota|Total namespace storage used'),
footerNote: s__(
'UsageQuota|This is the total amount of storage used across your projects within this namespace.',
),
link: {
text: s__('UsageQuota|Learn more about usage quotas'),
url: helpPagePath('user/usage_quotas'),
},
};
},
excessUsage() {
return {
usage: this.formatSizeAndSplit(this.rootStorageStatistics.totalRepositorySizeExcess),
description: s__('UsageQuota|Total excess storage used'),
footerNote: s__(
'UsageQuota|This is the total amount of storage used by projects above the free %{actualRepositorySizeLimit} storage limit.',
),
link: {
text: s__('UsageQuota|Learn more about excess storage usage'),
url: helpPagePath('user/usage_quotas', { anchor: 'excess-storage-usage' }),
},
};
},
purchasedUsage() {
const {
totalRepositorySizeExcess,
additionalPurchasedStorageSize,
} = this.rootStorageStatistics;
return this.purchaseStorageUrl
? {
usage: this.formatSizeAndSplit(
Math.max(0, additionalPurchasedStorageSize - totalRepositorySizeExcess),
),
usageTotal: this.formatSizeAndSplit(additionalPurchasedStorageSize),
description: s__('UsageQuota|Purchased storage available'),
link: {
text: s__('UsageQuota|Purchase more storage'),
url: this.purchaseStorageUrl,
},
}
: null;
},
},
methods: {
/**
* The formatUsageSize 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.
*
* @params {Number} size size in bytes
* @returns {Object} value and unit of formatted size
*/
formatSizeAndSplit(size) {
const formattedSize = formatUsageSize(size);
return {
value: formattedSize.slice(0, -3),
unit: formattedSize.slice(-3),
};
},
},
};
</script>
<template>
<div class="gl-display-flex gl-sm-flex-direction-column">
<usage-statistics-card
data-testid="total-usage"
:usage="totalUsage.usage"
:link="totalUsage.link"
:description="totalUsage.description"
css-class="gl-mr-4"
/>
<usage-statistics-card
data-testid="excess-usage"
:usage="excessUsage.usage"
:link="excessUsage.link"
:description="excessUsage.description"
css-class="gl-mx-4"
/>
<usage-statistics-card
v-if="purchasedUsage"
data-testid="purchased-usage"
:usage="purchasedUsage.usage"
:usage-total="purchasedUsage.usageTotal"
:link="purchasedUsage.link"
:description="purchasedUsage.description"
css-class="gl-ml-4"
>
<template #footer="{ 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,
},
usageTotal: {
type: Object,
required: false,
default: null,
},
cssClass: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="gl-p-5 gl-my-5 gl-bg-gray-10 gl-flex-grow-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>
<template v-if="usageTotal">
<span class="gl-font-size-h-display gl-font-weight-bold">/</span>
<gl-sprintf :message="__('%{size} %{unit}')">
<template #size>
<span class="gl-font-size-h-display gl-font-weight-bold">{{ usageTotal.value }}</span>
</template>
<template #unit>
<span class="gl-font-lg gl-font-weight-bold">{{ usageTotal.unit }}</span>
</template>
</gl-sprintf>
</template>
</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 gl-text-gray-900 gl-font-sm gl-white-space-normal"
data-testid="statistics-card-footer"
>
<slot v-bind="{ link }" name="footer">
<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>
export const NONE_THRESHOLD = 'none';
export const INFO_THRESHOLD = 'info';
export const WARNING_THRESHOLD = 'warning';
export const ALERT_THRESHOLD = 'alert';
export const ERROR_THRESHOLD = 'error';
export const STORAGE_USAGE_THRESHOLDS = {
[NONE_THRESHOLD]: 0.0,
[INFO_THRESHOLD]: 0.5,
[WARNING_THRESHOLD]: 0.75,
[ALERT_THRESHOLD]: 0.95,
[ERROR_THRESHOLD]: 1.0,
};
export const PROJECTS_PER_PAGE = 20;
export const SKELETON_LOADER_ROWS = {
desktop: PROJECTS_PER_PAGE,
mobile: 5,
};
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-other-storage-counter-app');
const {
namespacePath,
helpPagePath,
purchaseStorageUrl,
isTemporaryStorageIncreaseVisible,
} = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
render(h) {
return h(App, {
props: {
namespacePath,
helpPagePath,
purchaseStorageUrl,
isTemporaryStorageIncreaseVisible,
},
});
},
});
};
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getStorageCounter(
$fullPath: ID!
$withExcessStorageData: Boolean = false
$searchTerm: String = ""
$first: Int!
$after: String
$before: String
) {
namespace(fullPath: $fullPath) {
id
name
storageSizeLimit
actualRepositorySizeLimit @include(if: $withExcessStorageData)
additionalPurchasedStorageSize @include(if: $withExcessStorageData)
totalRepositorySizeExcess @include(if: $withExcessStorageData)
totalRepositorySize @include(if: $withExcessStorageData)
containsLockedProjects @include(if: $withExcessStorageData)
repositorySizeExcessProjectCount @include(if: $withExcessStorageData)
rootStorageStatistics {
storageSize
repositorySize
lfsObjectsSize
buildArtifactsSize
pipelineArtifactsSize
packagesSize
wikiSize
snippetsSize
uploadsSize
}
projects(
includeSubgroups: true
search: $searchTerm
first: $first
after: $after
before: $before
sort: STORAGE
) {
nodes {
id
fullPath
nameWithNamespace
avatarUrl
webUrl
name
repositorySizeExcess @include(if: $withExcessStorageData)
actualRepositorySizeLimit @include(if: $withExcessStorageData)
statistics {
commitCount
storageSize
repositorySize
lfsObjectsSize
buildArtifactsSize
packagesSize
wikiSize
snippetsSize
uploadsSize
}
}
pageInfo {
...PageInfo
}
}
}
}
import { numberToHumanSize, bytesToKiB } from '~/lib/utils/number_utils';
import { getFormatter, SUPPORTED_FORMATS } from '~/lib/utils/unit_format';
import { STORAGE_USAGE_THRESHOLDS } from './constants';
export function usageRatioToThresholdLevel(currentUsageRatio) {
let currentLevel = Object.keys(STORAGE_USAGE_THRESHOLDS)[0];
Object.keys(STORAGE_USAGE_THRESHOLDS).forEach((thresholdLevel) => {
if (currentUsageRatio >= STORAGE_USAGE_THRESHOLDS[thresholdLevel])
currentLevel = thresholdLevel;
});
return currentLevel;
}
/**
* Formats given bytes to formatted human readable size
*
* We want to display all units above bytes. Hence
* converting bytesToKiB before passing it to
* `getFormatter`
* @param {Number} size size in bytes
* @returns {String}
*/
export const formatUsageSize = (size) => {
const formatDecimalBytes = getFormatter(SUPPORTED_FORMATS.kibibytes);
return formatDecimalBytes(bytesToKiB(size), 1);
};
/**
* Parses each project to add additional purchased data
* equally so that locked projects can be unlocked.
*
* For example, if a group contains the below projects and
* project 2, 3 have exceeded the default 10.0 GB limit.
* 2 and 3 will remain locked until user purchases additional
* data.
*
* Project 1: 7.0GB
* Project 2: 13.0GB Locked
* Project 3: 12.0GB Locked
*
* If user purchases X GB, it will be equally available
* to all the locked projects for further use.
*
* @param {Object} data project
* @param {Number} purchasedStorageRemaining Remaining purchased data in bytes
* @returns {Object}
*/
export const calculateUsedAndRemStorage = (project, purchasedStorageRemaining) => {
// We only consider repo size and lfs object size as of %13.5
const totalCalculatedUsedStorage =
project.statistics.repositorySize + project.statistics.lfsObjectsSize;
// If a project size is above the default limit, then the remaining
// storage value will be calculated on top of the project size as
// opposed to the default limit.
// This
const totalCalculatedStorageLimit =
totalCalculatedUsedStorage > project.actualRepositorySizeLimit
? totalCalculatedUsedStorage + purchasedStorageRemaining
: project.actualRepositorySizeLimit + purchasedStorageRemaining;
return {
...project,
totalCalculatedUsedStorage,
totalCalculatedStorageLimit,
};
};
/**
* Parses projects coming in from GraphQL response
* and patches each project with purchased related
* data
*
* @param {Array} params.projects list of projects
* @param {Number} params.additionalPurchasedStorageSize Amt purchased in bytes
* @param {Number} params.totalRepositorySizeExcess Sum of excess amounts on all projects
* @returns {Array}
*/
export const parseProjects = ({
projects,
additionalPurchasedStorageSize = 0,
totalRepositorySizeExcess = 0,
}) => {
const purchasedStorageRemaining = Math.max(
0,
additionalPurchasedStorageSize - totalRepositorySizeExcess,
);
return projects.nodes.map((project) =>
calculateUsedAndRemStorage(project, purchasedStorageRemaining),
);
};
/**
* This method parses the results from `getStorageCounter`
* call.
*
* `rootStorageStatistics` will be sent as null until an
* event happens to trigger the storage count.
* For that reason we have to verify if `storageSize` is sent or
* if we should render N/A
*
* @param {Object} data graphql result
* @returns {Object}
*/
export const parseGetStorageResults = (data) => {
const {
namespace: {
projects,
storageSizeLimit,
totalRepositorySize,
containsLockedProjects,
totalRepositorySizeExcess,
rootStorageStatistics = {},
actualRepositorySizeLimit,
additionalPurchasedStorageSize,
repositorySizeExcessProjectCount,
},
} = data || {};
const totalUsage = rootStorageStatistics?.storageSize
? numberToHumanSize(rootStorageStatistics.storageSize)
: 'N/A';
return {
projects: {
data: parseProjects({
projects,
additionalPurchasedStorageSize,
totalRepositorySizeExcess,
}),
pageInfo: projects.pageInfo,
},
additionalPurchasedStorageSize,
actualRepositorySizeLimit,
containsLockedProjects,
repositorySizeExcessProjectCount,
totalRepositorySize,
totalRepositorySizeExcess,
totalUsage,
rootStorageStatistics,
limit: storageSizeLimit,
};
};
import otherStorageCounter from 'ee/other_storage_counter';
import SeatUsageApp from 'ee/seat_usage'; import SeatUsageApp from 'ee/seat_usage';
import storageCounter from 'ee/storage_counter'; import storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
...@@ -24,10 +23,6 @@ const initVueApps = () => { ...@@ -24,10 +23,6 @@ const initVueApps = () => {
if (document.querySelector('#js-storage-counter-app')) { if (document.querySelector('#js-storage-counter-app')) {
storageCounter(); storageCounter();
} }
if (document.querySelector('#js-other-storage-counter-app')) {
otherStorageCounter();
}
}; };
initVueApps(); initVueApps();
......
import ciMinutesUsage from 'ee/ci_minutes_usage'; import ciMinutesUsage from 'ee/ci_minutes_usage';
import otherStorageCounter from 'ee/other_storage_counter';
import storageCounter from 'ee/storage_counter'; import storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs'; import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
...@@ -14,15 +13,4 @@ if (document.querySelector('#js-storage-counter-app')) { ...@@ -14,15 +13,4 @@ if (document.querySelector('#js-storage-counter-app')) {
}); });
} }
if (document.querySelector('#js-other-storage-counter-app')) {
otherStorageCounter();
// eslint-disable-next-line no-new
new LinkedTabs({
defaultAction: '#pipelines-quota-tab',
parentEl: '.js-other-storage-tabs',
hashedTabs: true,
});
}
ciMinutesUsage(); ciMinutesUsage();
- page_title s_("UsageQuota|Usage") - page_title s_("UsageQuota|Usage")
- url_to_purchase_storage = purchase_storage_url if purchase_storage_link_enabled?(@group) - url_to_purchase_storage = purchase_storage_url if purchase_storage_link_enabled?(@group)
- other_storage_enabled = Feature.enabled?(:other_storage_tab, @group)
- if show_product_purchase_success_alert? - if show_product_purchase_success_alert?
= render 'product_purchase_success_alert', product_name: params[:purchased_product] = render 'product_purchase_success_alert', product_name: params[:purchased_product]
...@@ -23,10 +22,6 @@ ...@@ -23,10 +22,6 @@
%li.nav-item %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 } %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') = s_('UsageQuota|Storage')
- if other_storage_enabled
%li.nav-item
%a.nav-link#storage-quota{ data: { toggle: "tab", action: '#other-storage-quota-tab' }, href: '#other-storage-quota-tab', 'aria-controls': '#other-storage-quota-tab', 'aria-selected': false }
= s_('UsageQuota|Other Storage')
.tab-content .tab-content
.tab-pane#seats-quota-tab .tab-pane#seats-quota-tab
#js-seat-usage-app{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv) } } #js-seat-usage-app{ data: { namespace_id: @group.id, namespace_name: @group.name, seat_usage_export_path: group_seat_usage_path(@group, format: :csv) } }
...@@ -35,6 +30,3 @@ ...@@ -35,6 +30,3 @@
locals: { namespace: @group, projects: @projects } locals: { namespace: @group, projects: @projects }
.tab-pane#storage-quota-tab .tab-pane#storage-quota-tab
#js-storage-counter-app{ data: { namespace_path: @group.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s } } #js-storage-counter-app{ data: { namespace_path: @group.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s } }
- if other_storage_enabled
.tab-pane#other-storage-quota-tab
#js-other-storage-counter-app{ data: { namespace_path: @group.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s } }
- page_title s_("UsageQuota|Usage") - page_title s_("UsageQuota|Usage")
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
- url_to_purchase_storage = purchase_storage_url if purchase_storage_link_enabled?(@namespace) - url_to_purchase_storage = purchase_storage_url if purchase_storage_link_enabled?(@namespace)
- other_storage_enabled = Feature.enabled?(:other_storage_tab, @namespace)
%h3.page-title %h3.page-title
= s_('UsageQuota|Usage Quotas') = s_('UsageQuota|Usage Quotas')
...@@ -18,16 +17,9 @@ ...@@ -18,16 +17,9 @@
%li.nav-item %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 } %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') = s_('UsageQuota|Storage')
- if other_storage_enabled
%li.nav-item
%a.nav-link#storage-quota{ data: { toggle: "tab", action: '#other-storage-quota-tab' }, href: '#other-storage-quota-tab', 'aria-controls': '#other-storage-quota-tab', 'aria-selected': false }
= s_('UsageQuota|Other Storage')
.tab-content .tab-content
.tab-pane#pipelines-quota-tab .tab-pane#pipelines-quota-tab
= render "namespaces/pipelines_quota/list", = render "namespaces/pipelines_quota/list",
locals: { namespace: @namespace, projects: @projects } locals: { namespace: @namespace, projects: @projects }
.tab-pane#storage-quota-tab .tab-pane#storage-quota-tab
#js-storage-counter-app{ data: { namespace_path: @namespace.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@namespace).to_s } } #js-storage-counter-app{ data: { namespace_path: @namespace.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@namespace).to_s } }
- if other_storage_enabled
.tab-pane#other-storage-quota-tab
#js-other-storage-counter-app{ data: { namespace_path: @namespace.full_path, help_page_path: help_page_path('user/usage_quotas.md'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@namespace).to_s } }
import { mount } from '@vue/test-utils';
import StorageApp from 'ee/other_storage_counter/components/app.vue';
import Project from 'ee/other_storage_counter/components/project.vue';
import ProjectsTable from 'ee/other_storage_counter/components/projects_table.vue';
import StorageInlineAlert from 'ee/other_storage_counter/components/storage_inline_alert.vue';
import TemporaryStorageIncreaseModal from 'ee/other_storage_counter/components/temporary_storage_increase_modal.vue';
import UsageGraph from 'ee/other_storage_counter/components/usage_graph.vue';
import UsageStatistics from 'ee/other_storage_counter/components/usage_statistics.vue';
import { formatUsageSize } from 'ee/other_storage_counter/utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { namespaceData, withRootStorageStatistics } from '../mock_data';
const TEST_LIMIT = 1000;
describe('Storage counter app', () => {
let wrapper;
const findTotalUsage = () => wrapper.find("[data-testid='total-usage']");
const findPurchaseStorageLink = () => wrapper.find("[data-testid='purchase-storage-link']");
const findTemporaryStorageIncreaseButton = () =>
wrapper.find("[data-testid='temporary-storage-increase-button']");
const findUsageGraph = () => wrapper.find(UsageGraph);
const findUsageStatistics = () => wrapper.find(UsageStatistics);
const findStorageInlineAlert = () => wrapper.find(StorageInlineAlert);
const findProjectsTable = () => wrapper.find(ProjectsTable);
const findPrevButton = () => wrapper.find('[data-testid="prevButton"]');
const findNextButton = () => wrapper.find('[data-testid="nextButton"]');
const createComponent = ({
props = {},
loading = false,
additionalRepoStorageByNamespace = false,
namespace = {},
} = {}) => {
const $apollo = {
queries: {
namespace: {
loading,
},
},
};
wrapper = mount(StorageApp, {
propsData: { namespacePath: 'h5bp', helpPagePath: 'help', ...props },
mocks: { $apollo },
directives: {
GlModalDirective: createMockDirective(),
},
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
},
},
data() {
return {
namespace,
};
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders the 2 projects', async () => {
wrapper.setData({
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
expect(wrapper.findAll(Project)).toHaveLength(3);
});
describe('limit', () => {
it('when limit is set it renders limit information', async () => {
wrapper.setData({
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(formatUsageSize(namespaceData.limit));
});
it('when limit is 0 it does not render limit information', async () => {
wrapper.setData({
namespace: { ...namespaceData, limit: 0 },
});
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain(formatUsageSize(0));
});
});
describe('with rootStorageStatistics information', () => {
it('renders total usage', async () => {
wrapper.setData({
namespace: withRootStorageStatistics,
});
await wrapper.vm.$nextTick();
expect(findTotalUsage().text()).toContain(withRootStorageStatistics.totalUsage);
});
});
describe('with additional_repo_storage_by_namespace feature', () => {
it('usage_graph component hidden is when feature is false', async () => {
wrapper.setData({
namespace: withRootStorageStatistics,
});
await wrapper.vm.$nextTick();
expect(findUsageGraph().exists()).toBe(true);
expect(findUsageStatistics().exists()).toBe(false);
expect(findStorageInlineAlert().exists()).toBe(false);
});
it('usage_statistics component is rendered when feature is true', async () => {
createComponent({
additionalRepoStorageByNamespace: true,
namespace: withRootStorageStatistics,
});
await wrapper.vm.$nextTick();
expect(findUsageStatistics().exists()).toBe(true);
expect(findUsageGraph().exists()).toBe(false);
expect(findStorageInlineAlert().exists()).toBe(true);
});
});
describe('without rootStorageStatistics information', () => {
it('renders N/A', async () => {
wrapper.setData({
namespace: namespaceData,
});
await wrapper.vm.$nextTick();
expect(findTotalUsage().text()).toContain('N/A');
});
});
describe('purchase storage link', () => {
describe('when purchaseStorageUrl is not set', () => {
it('does not render an additional link', () => {
expect(findPurchaseStorageLink().exists()).toBe(false);
});
});
describe('when purchaseStorageUrl is set', () => {
beforeEach(() => {
createComponent({ props: { purchaseStorageUrl: 'customers.gitlab.com' } });
});
it('does render link', () => {
const link = findPurchaseStorageLink();
expect(link).toExist();
expect(link.attributes('href')).toBe('customers.gitlab.com');
});
});
});
describe('temporary storage increase', () => {
describe.each`
props | isVisible
${{}} | ${false}
${{ isTemporaryStorageIncreaseVisible: 'false' }} | ${false}
${{ isTemporaryStorageIncreaseVisible: 'true' }} | ${true}
`('with $props', ({ props, isVisible }) => {
beforeEach(() => {
createComponent({ props });
});
it(`renders button = ${isVisible}`, () => {
expect(findTemporaryStorageIncreaseButton().exists()).toBe(isVisible);
});
});
describe('when temporary storage increase is visible', () => {
beforeEach(() => {
createComponent({ props: { isTemporaryStorageIncreaseVisible: 'true' } });
wrapper.setData({
namespace: {
...namespaceData,
limit: TEST_LIMIT,
},
});
});
it('binds button to modal', () => {
const { value } = getBinding(
findTemporaryStorageIncreaseButton().element,
'gl-modal-directive',
);
// Check for truthiness so we're assured we're not comparing two undefineds
expect(value).toBeTruthy();
expect(value).toEqual(StorageApp.modalId);
});
it('renders modal', () => {
expect(wrapper.find(TemporaryStorageIncreaseModal).props()).toEqual({
limit: formatUsageSize(TEST_LIMIT),
modalId: StorageApp.modalId,
});
});
});
});
describe('filtering projects', () => {
beforeEach(() => {
createComponent({
additionalRepoStorageByNamespace: true,
namespace: withRootStorageStatistics,
});
});
const sampleSearchTerm = 'GitLab';
const sampleShortSearchTerm = '12';
it('triggers search if user enters search input', () => {
expect(wrapper.vm.searchTerm).toBe('');
findProjectsTable().vm.$emit('search', sampleSearchTerm);
expect(wrapper.vm.searchTerm).toBe(sampleSearchTerm);
});
it('triggers search if user clears the entered search input', () => {
const projectsTable = findProjectsTable();
expect(wrapper.vm.searchTerm).toBe('');
projectsTable.vm.$emit('search', sampleSearchTerm);
expect(wrapper.vm.searchTerm).toBe(sampleSearchTerm);
projectsTable.vm.$emit('search', '');
expect(wrapper.vm.searchTerm).toBe('');
});
it('does not trigger search if user enters short search input', () => {
expect(wrapper.vm.searchTerm).toBe('');
findProjectsTable().vm.$emit('search', sampleShortSearchTerm);
expect(wrapper.vm.searchTerm).toBe('');
});
});
describe('renders projects table pagination component', () => {
const namespaceWithPageInfo = {
namespace: {
...withRootStorageStatistics,
projects: {
...withRootStorageStatistics.projects,
pageInfo: {
hasPreviousPage: false,
hasNextPage: true,
},
},
},
};
beforeEach(() => {
createComponent(namespaceWithPageInfo);
});
it('with disabled "Prev" button', () => {
expect(findPrevButton().attributes().disabled).toBe('disabled');
});
it('with enabled "Next" button', () => {
expect(findNextButton().attributes().disabled).toBeUndefined();
});
});
});
import { shallowMount } from '@vue/test-utils';
import Project from 'ee/other_storage_counter/components/project.vue';
import StorageRow from 'ee/other_storage_counter/components/storage_row.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = () => {
wrapper = shallowMount(Project, {
propsData: {
project: projects[1],
},
});
};
const findTableRow = () => wrapper.find('[data-testid="projectTableRow"]');
const findStorageRow = () => wrapper.find(StorageRow);
describe('Storage Counter project component', () => {
beforeEach(() => {
createComponent();
});
it('renders project avatar', () => {
expect(wrapper.find(ProjectAvatar).exists()).toBe(true);
});
it('renders project name', () => {
expect(wrapper.text()).toContain(projects[1].nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(numberToHumanSize(projects[1].statistics.storageSize));
});
describe('toggle row', () => {
describe('on click', () => {
it('toggles isOpen', () => {
expect(findStorageRow().exists()).toBe(false);
findTableRow().trigger('click');
wrapper.vm.$nextTick(() => {
expect(findStorageRow().exists()).toBe(true);
findTableRow().trigger('click');
wrapper.vm.$nextTick(() => {
expect(findStorageRow().exists()).toBe(false);
});
});
});
});
});
});
import { GlIcon, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import ProjectWithExcessStorage from 'ee/other_storage_counter/components/project_with_excess_storage.vue';
import { formatUsageSize } from 'ee/other_storage_counter/utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ProjectAvatar from '~/vue_shared/components/deprecated_project_avatar/default.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = (propsData = {}) => {
wrapper = shallowMount(ProjectWithExcessStorage, {
propsData: {
project: projects[0],
additionalPurchasedStorageSize: 0,
...propsData,
},
directives: {
GlTooltip: createMockDirective(),
},
});
};
const findTableRow = () => wrapper.find('[data-testid="projectTableRow"]');
const findWarningIcon = () =>
wrapper.findAll(GlIcon).wrappers.find((w) => w.props('name') === 'status_warning');
const findProjectLink = () => wrapper.find(GlLink);
const getWarningIconTooltipText = () => getBinding(findWarningIcon().element, 'gl-tooltip').value;
describe('Storage Counter project component', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
describe('without extra storage purchased', () => {
it('renders project avatar', () => {
expect(wrapper.find(ProjectAvatar).exists()).toBe(true);
});
it('renders project name', () => {
expect(wrapper.text()).toContain(projects[0].nameWithNamespace);
});
it('renders formatted storage size', () => {
expect(wrapper.text()).toContain(formatUsageSize(projects[0].statistics.storageSize));
});
it('does not render the warning icon if project is not in error state', () => {
expect(findWarningIcon()).toBe(undefined);
});
it('render row without error state background', () => {
expect(findTableRow().classes('gl-bg-red-50')).toBe(false);
});
describe('renders the row in error state', () => {
beforeEach(() => {
createComponent({ project: projects[2] });
});
it('with error state background', () => {
expect(findTableRow().classes('gl-bg-red-50')).toBe(true);
});
it('with project link in error state', () => {
expect(findProjectLink().classes('gl-text-red-500!')).toBe(true);
});
it('with error icon', () => {
expect(findWarningIcon().exists()).toBe(true);
});
it('with tooltip', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is locked because it is using 97.7KiB of free storage and there is no purchased storage available.',
);
});
});
describe('renders the row in warning state', () => {
beforeEach(() => {
createComponent({ project: projects[1] });
});
it('with warning state background', () => {
expect(findTableRow().classes('gl-bg-orange-50')).toBe(true);
});
it('with project link in default gray state', () => {
expect(findProjectLink().classes('gl-text-gray-900!')).toBe(true);
});
it('with warning icon', () => {
expect(findWarningIcon().exists()).toBe(true);
});
it('with tooltip', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is near the free 97.7KiB limit and at risk of being locked.',
);
});
});
});
describe('with extra storage purchased', () => {
describe('if projects is in error state', () => {
beforeEach(() => {
createComponent({
project: projects[2],
additionalPurchasedStorageSize: 100000,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders purchased storage specific error tooltip ', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is locked because it used 97.7KiB of free storage and all the purchased storage.',
);
});
});
describe('if projects is in warning state', () => {
beforeEach(() => {
createComponent({
project: projects[1],
additionalPurchasedStorageSize: 100000,
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders purchased storage specific warning tooltip ', () => {
expect(getWarningIconTooltipText().title).toBe(
'This project is at risk of being locked because purchased storage is running low.',
);
});
});
});
});
import { mount } from '@vue/test-utils';
import ProjectsSkeletonLoader from 'ee/other_storage_counter/components/projects_skeleton_loader.vue';
describe('ProjectsSkeletonLoader', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = mount(ProjectsSkeletonLoader, {
propsData: {
...props,
},
});
};
const findDesktopLoader = () => wrapper.find('[data-testid="desktop-loader"]');
const findMobileLoader = () => wrapper.find('[data-testid="mobile-loader"]');
beforeEach(createComponent);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('desktop loader', () => {
it('produces 20 rows', () => {
expect(findDesktopLoader().findAll('rect[width="1000"]')).toHaveLength(20);
});
it('has the correct classes', () => {
expect(findDesktopLoader().classes()).toEqual([
'gl-display-none',
'gl-md-display-flex',
'gl-flex-direction-column',
]);
});
});
describe('mobile loader', () => {
it('produces 5 rows', () => {
expect(findMobileLoader().findAll('rect[height="172"]')).toHaveLength(5);
});
it('has the correct classes', () => {
expect(findMobileLoader().classes()).toEqual([
'gl-flex-direction-column',
'gl-md-display-none',
]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import Project from 'ee/other_storage_counter/components/project.vue';
import ProjectWithExcessStorage from 'ee/other_storage_counter/components/project_with_excess_storage.vue';
import ProjectsTable from 'ee/other_storage_counter/components/projects_table.vue';
import { projects } from '../mock_data';
let wrapper;
const createComponent = ({ additionalRepoStorageByNamespace = false } = {}) => {
const stubs = {
'anonymous-stub': additionalRepoStorageByNamespace ? ProjectWithExcessStorage : Project,
};
wrapper = shallowMount(ProjectsTable, {
propsData: {
projects,
additionalPurchasedStorageSize: 0,
},
stubs,
provide: {
glFeatures: {
additionalRepoStorageByNamespace,
},
},
});
};
const findTableRows = () => wrapper.findAll(Project);
const findTableRowsWithExcessStorage = () => wrapper.findAll(ProjectWithExcessStorage);
describe('Usage Quotas project table component', () => {
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders regular project rows by default', () => {
expect(findTableRows()).toHaveLength(3);
expect(findTableRowsWithExcessStorage()).toHaveLength(0);
});
describe('with additional repo storage feature flag ', () => {
beforeEach(() => {
createComponent({ additionalRepoStorageByNamespace: true });
});
it('renders table row with excess storage', () => {
expect(findTableRowsWithExcessStorage()).toHaveLength(3);
});
it('renders excess storage rows with error state', () => {
const rowsWithError = findTableRowsWithExcessStorage().filter((r) =>
r.classes('gl-bg-red-50'),
);
expect(rowsWithError).toHaveLength(1);
});
});
});
import { GlAlert } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import StorageInlineAlert from 'ee/other_storage_counter/components/storage_inline_alert.vue';
const GB_IN_BYTES = 1_074_000_000;
const THIRTEEN_GB_IN_BYTES = 13 * GB_IN_BYTES;
const TEN_GB_IN_BYTES = 10 * GB_IN_BYTES;
const FIVE_GB_IN_BYTES = 5 * GB_IN_BYTES;
const THREE_GB_IN_BYTES = 3 * GB_IN_BYTES;
describe('StorageInlineAlert', () => {
let wrapper;
function mountComponent(props) {
wrapper = shallowMount(StorageInlineAlert, {
propsData: props,
});
}
const findAlert = () => wrapper.find(GlAlert);
describe('no excess storage and no purchase', () => {
beforeEach(() => {
mountComponent({
containsLockedProjects: false,
repositorySizeExcessProjectCount: 0,
totalRepositorySizeExcess: 0,
totalRepositorySize: FIVE_GB_IN_BYTES,
additionalPurchasedStorageSize: 0,
actualRepositorySizeLimit: TEN_GB_IN_BYTES,
});
});
it('does not render an alert', () => {
expect(findAlert().exists()).toBe(false);
});
});
describe('excess storage and no purchase', () => {
beforeEach(() => {
mountComponent({
containsLockedProjects: true,
repositorySizeExcessProjectCount: 1,
totalRepositorySizeExcess: THREE_GB_IN_BYTES,
totalRepositorySize: THIRTEEN_GB_IN_BYTES,
additionalPurchasedStorageSize: 0,
actualRepositorySizeLimit: TEN_GB_IN_BYTES,
});
});
it('renders danger variant alert', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().props('variant')).toBe('danger');
});
it('renders human readable repositoryFreeLimit', () => {
expect(findAlert().text()).toBe(
'You have reached the free storage limit of 10.0GiB on 1 project. To unlock them, please purchase additional storage.',
);
});
});
describe('excess storage below purchase limit', () => {
beforeEach(() => {
mountComponent({
containsLockedProjects: false,
repositorySizeExcessProjectCount: 0,
totalRepositorySizeExcess: THREE_GB_IN_BYTES,
totalRepositorySize: THIRTEEN_GB_IN_BYTES,
additionalPurchasedStorageSize: FIVE_GB_IN_BYTES,
actualRepositorySizeLimit: TEN_GB_IN_BYTES,
});
});
it('renders info variant alert', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().props('variant')).toBe('info');
});
it('renders text explaining storage', () => {
expect(findAlert().text()).toBe(
'When you purchase additional storage, we automatically unlock projects that were locked when you reached the 10.0GiB limit.',
);
});
});
describe('excess storage above purchase limit', () => {
beforeEach(() => {
mountComponent({
containsLockedProjects: true,
repositorySizeExcessProjectCount: 1,
totalRepositorySizeExcess: THREE_GB_IN_BYTES,
totalRepositorySize: THIRTEEN_GB_IN_BYTES,
additionalPurchasedStorageSize: THREE_GB_IN_BYTES,
actualRepositorySizeLimit: TEN_GB_IN_BYTES,
});
});
it('renders danger alert', () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().props('variant')).toBe('danger');
});
});
});
import { shallowMount } from '@vue/test-utils';
import StorageRow from 'ee/other_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));
});
});
import { GlModal } from '@gitlab/ui';
import { mount, shallowMount } from '@vue/test-utils';
import TemporaryStorageIncreaseModal from 'ee/other_storage_counter/components/temporary_storage_increase_modal.vue';
const TEST_LIMIT = '8 bytes';
const TEST_MODAL_ID = 'test-modal-id';
describe('Temporary storage increase modal', () => {
let wrapper;
const createComponent = (mountFn, props = {}) => {
wrapper = mountFn(TemporaryStorageIncreaseModal, {
propsData: {
modalId: TEST_MODAL_ID,
limit: TEST_LIMIT,
...props,
},
});
};
const findModal = () => wrapper.find(GlModal);
const showModal = () => {
findModal().vm.show();
return wrapper.vm.$nextTick();
};
const findModalText = () => document.body.innerText;
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('shows modal message', async () => {
createComponent(mount);
await showModal();
const text = findModalText();
expect(text).toContain('GitLab allows you a free, one-time storage increase.');
expect(text).toContain(`your original storage limit of ${TEST_LIMIT} applies.`);
});
it('passes along modalId', () => {
createComponent(shallowMount);
expect(findModal().attributes('modalid')).toBe(TEST_MODAL_ID);
});
});
import { shallowMount } from '@vue/test-utils';
import UsageGraph from 'ee/other_storage_counter/components/usage_graph.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
let data;
let wrapper;
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.flex);
}
describe('Storage Counter usage graph component', () => {
beforeEach(() => {
data = {
rootStorageStatistics: {
wikiSize: 5000,
repositorySize: 4000,
packagesSize: 3000,
lfsObjectsSize: 2000,
buildArtifactsSize: 500,
pipelineArtifactsSize: 500,
snippetsSize: 2000,
storageSize: 17000,
uploadsSize: 1000,
},
limit: 2000,
};
mountComponent(data);
});
afterEach(() => {
wrapper.destroy();
});
it('renders the legend in order', () => {
const types = wrapper.findAll('[data-testid="storage-type-legend"]');
const {
buildArtifactsSize,
pipelineArtifactsSize,
lfsObjectsSize,
packagesSize,
repositorySize,
wikiSize,
snippetsSize,
uploadsSize,
} = data.rootStorageStatistics;
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(
`Snippets ${numberToHumanSize(snippetsSize)}`,
);
expect(types.at(5).text()).toMatchInterpolatedText(
`Artifacts ${numberToHumanSize(buildArtifactsSize + pipelineArtifactsSize)}`,
);
expect(types.at(6).text()).toMatchInterpolatedText(`Uploads ${numberToHumanSize(uploadsSize)}`);
});
describe('when storage type is not used', () => {
beforeEach(() => {
data.rootStorageStatistics.wikiSize = 0;
mountComponent(data);
});
it('filters the storage type', () => {
expect(wrapper.text()).not.toContain('Wikis');
});
});
describe('when there is no storage usage', () => {
beforeEach(() => {
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 flex values', () => {
expect(findStorageTypeUsagesSerialized()).toStrictEqual([
'0.29411764705882354',
'0.23529411764705882',
'0.17647058823529413',
'0.11764705882352941',
'0.11764705882352941',
'0.058823529411764705',
'0.058823529411764705',
]);
});
});
describe('when storage exceeds limit', () => {
beforeEach(() => {
data.limit = data.rootStorageStatistics.storageSize - 1;
mountComponent(data);
});
it('it does render correclty', () => {
expect(findStorageTypeUsagesSerialized()).toStrictEqual([
'0.29411764705882354',
'0.23529411764705882',
'0.17647058823529413',
'0.11764705882352941',
'0.11764705882352941',
'0.058823529411764705',
'0.058823529411764705',
]);
});
});
});
import { GlButton, GlLink, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import UsageStatistics from 'ee/other_storage_counter/components/usage_statistics.vue';
import UsageStatisticsCard from 'ee/other_storage_counter/components/usage_statistics_card.vue';
import { withRootStorageStatistics } from '../mock_data';
describe('Usage Statistics component', () => {
let wrapper;
const createComponent = (props = {}) => {
wrapper = shallowMount(UsageStatistics, {
propsData: {
rootStorageStatistics: {
totalRepositorySize: withRootStorageStatistics.totalRepositorySize,
actualRepositorySizeLimit: withRootStorageStatistics.actualRepositorySizeLimit,
totalRepositorySizeExcess: withRootStorageStatistics.totalRepositorySizeExcess,
additionalPurchasedStorageSize: withRootStorageStatistics.additionalPurchasedStorageSize,
},
...props,
},
stubs: {
UsageStatisticsCard,
GlSprintf,
GlLink,
},
});
};
const getStatisticsCards = () => wrapper.findAll(UsageStatisticsCard);
const getStatisticsCard = (testId) => wrapper.find(`[data-testid="${testId}"]`);
const findGlLinkInCard = (cardName) =>
getStatisticsCard(cardName).find('[data-testid="statistics-card-footer"]').find(GlLink);
describe('with purchaseStorageUrl passed', () => {
beforeEach(() => {
createComponent({
purchaseStorageUrl: 'some-fancy-url',
});
});
afterEach(() => {
wrapper.destroy();
});
it('renders three statistics cards', () => {
expect(getStatisticsCards()).toHaveLength(3);
});
it('renders URL in total usage card footer', () => {
const url = findGlLinkInCard('total-usage');
expect(url.attributes('href')).toBe('/help/user/usage_quotas');
});
it('renders URL in excess usage card footer', () => {
const url = findGlLinkInCard('excess-usage');
expect(url.attributes('href')).toBe('/help/user/usage_quotas#excess-storage-usage');
});
it('renders button in purchased usage card footer', () => {
expect(getStatisticsCard('purchased-usage').find(GlButton).exists()).toBe(true);
});
});
describe('with no purchaseStorageUrl', () => {
beforeEach(() => {
createComponent({
purchaseStorageUrl: null,
});
});
afterEach(() => {
wrapper.destroy();
});
it('does not render purchased usage card if purchaseStorageUrl is not provided', () => {
expect(getStatisticsCard('purchased-usage').exists()).toBe(false);
});
});
});
export const 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,
},
actualRepositorySizeLimit: 100000,
totalCalculatedUsedStorage: 41943,
totalCalculatedStorageLimit: 41943000,
},
{
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: 99000,
repositorySize: 0,
lfsObjectsSize: 0,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
actualRepositorySizeLimit: 100000,
totalCalculatedUsedStorage: 89000,
totalCalculatedStorageLimit: 99430,
},
{
id: '80',
fullPath: 'twit/twitter',
nameWithNamespace: 'Twitter',
avatarUrl: null,
webUrl: 'http://localhost:3001/twit/twitter',
name: 'Twitter',
statistics: {
commitCount: 0,
storageSize: 12933460,
repositorySize: 209710,
lfsObjectsSize: 209720,
buildArtifactsSize: 1272375,
packagesSize: 0,
},
actualRepositorySizeLimit: 100000,
totalCalculatedUsedStorage: 13143170,
totalCalculatedStorageLimit: 12143170,
},
];
export const namespaceData = {
totalUsage: 'N/A',
limit: 10000000,
projects: { data: projects },
};
export const withRootStorageStatistics = {
projects,
limit: 10000000,
totalUsage: 129334601,
containsLockedProjects: true,
repositorySizeExcessProjectCount: 1,
totalRepositorySizeExcess: 2321,
totalRepositorySize: 1002321,
additionalPurchasedStorageSize: 321,
actualRepositorySizeLimit: 1002321,
rootStorageStatistics: {
storageSize: 129334601,
repositorySize: 46012030,
lfsObjectsSize: 4329334601203,
buildArtifactsSize: 1272375,
packagesSize: 123123120,
wikiSize: 1000,
snippetsSize: 10000,
},
};
export const mockGetStorageCounterGraphQLResponse = {
nodes: projects.map((node) => node),
};
import {
usageRatioToThresholdLevel,
formatUsageSize,
parseProjects,
calculateUsedAndRemStorage,
} from 'ee/other_storage_counter/utils';
import { projects as mockProjectsData, mockGetStorageCounterGraphQLResponse } from './mock_data';
describe('UsageThreshold', () => {
it.each`
usageRatio | expectedLevel
${0} | ${'none'}
${0.4} | ${'none'}
${0.5} | ${'info'}
${0.9} | ${'warning'}
${0.99} | ${'alert'}
${1} | ${'error'}
${1.5} | ${'error'}
`('returns $expectedLevel from $usageRatio', ({ usageRatio, expectedLevel }) => {
expect(usageRatioToThresholdLevel(usageRatio)).toBe(expectedLevel);
});
});
describe('formatUsageSize', () => {
it.each`
input | expected
${0} | ${'0.0KiB'}
${999} | ${'1.0KiB'}
${1000} | ${'1.0KiB'}
${10240} | ${'10.0KiB'}
${1024 * 10 ** 5} | ${'97.7MiB'}
${10 ** 6} | ${'976.6KiB'}
${1024 * 10 ** 6} | ${'976.6MiB'}
${10 ** 8} | ${'95.4MiB'}
${1024 * 10 ** 8} | ${'95.4GiB'}
${10 ** 10} | ${'9.3GiB'}
${10 ** 12} | ${'931.3GiB'}
${10 ** 15} | ${'909.5TiB'}
`('returns $expected from $input', ({ input, expected }) => {
expect(formatUsageSize(input)).toBe(expected);
});
});
describe('calculateUsedAndRemStorage', () => {
it.each`
description | project | purchasedStorageRemaining | totalCalculatedUsedStorage | totalCalculatedStorageLimit
${'project within limit and purchased 0'} | ${mockProjectsData[0]} | ${0} | ${41943} | ${100000}
${'project within limit and purchased 10000'} | ${mockProjectsData[0]} | ${100000} | ${41943} | ${200000}
${'project in warning state and purchased 0'} | ${mockProjectsData[1]} | ${0} | ${0} | ${100000}
${'project in warning state and purchased 10000'} | ${mockProjectsData[1]} | ${100000} | ${0} | ${200000}
${'project in error state and purchased 0'} | ${mockProjectsData[2]} | ${0} | ${419430} | ${419430}
${'project in error state and purchased 10000'} | ${mockProjectsData[2]} | ${100000} | ${419430} | ${519430}
`(
'returns used: $totalCalculatedUsedStorage and remaining: $totalCalculatedStorageLimit storage for $description',
({
project,
purchasedStorageRemaining,
totalCalculatedUsedStorage,
totalCalculatedStorageLimit,
}) => {
const result = calculateUsedAndRemStorage(project, purchasedStorageRemaining);
expect(result.totalCalculatedUsedStorage).toBe(totalCalculatedUsedStorage);
expect(result.totalCalculatedStorageLimit).toBe(totalCalculatedStorageLimit);
},
);
});
describe('parseProjects', () => {
it('ensures all projects have totalCalculatedUsedStorage and totalCalculatedStorageLimit', () => {
const projects = parseProjects({
projects: mockGetStorageCounterGraphQLResponse,
additionalPurchasedStorageSize: 10000,
totalRepositorySizeExcess: 5000,
});
projects.forEach((project) => {
expect(project).toMatchObject({
totalCalculatedUsedStorage: expect.any(Number),
totalCalculatedStorageLimit: expect.any(Number),
});
});
});
});
...@@ -36013,9 +36013,6 @@ msgstr "" ...@@ -36013,9 +36013,6 @@ msgstr ""
msgid "UsageQuota|Learn more about usage quotas" msgid "UsageQuota|Learn more about usage quotas"
msgstr "" msgstr ""
msgid "UsageQuota|Other Storage"
msgstr ""
msgid "UsageQuota|Packages" msgid "UsageQuota|Packages"
msgstr "" 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