Commit 3a757dcc authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '322897-initial-other-storage-mvc' into 'master'

Duplicate "storage" tab to "other storage" [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!57121
parents 5dd53709 183a0909
---
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/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-flex-sm-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-flex-sm-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/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-flex-sm-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-flex-sm-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-flex-sm-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-fill-1 gl-white-space-nowrap" :class="cssClass">
<p class="mb-2">
<gl-sprintf :message="__('%{size} %{unit}')">
<template #size>
<span class="gl-font-size-h-display gl-font-weight-bold">{{ usage.value }}</span>
</template>
<template #unit>
<span class="gl-font-lg gl-font-weight-bold">{{ usage.unit }}</span>
</template>
</gl-sprintf>
<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 storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
import initSearchSettings from '~/search_settings';
......@@ -13,4 +14,15 @@ 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,
});
}
initSearchSettings();
import otherStorageCounter from 'ee/other_storage_counter';
import storageCounter from 'ee/storage_counter';
import LinkedTabs from '~/lib/utils/bootstrap_linked_tabs';
......@@ -11,3 +12,14 @@ if (document.querySelector('#js-storage-counter-app')) {
hashedTabs: true,
});
}
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,
});
}
- page_title s_("UsageQuota|Usage")
- url_to_purchase_storage = purchase_storage_url if purchase_storage_link_enabled?(@group)
- other_storage_enabled = Feature.enabled?(:other_storage_tab, @group)
%h3.page-title
= s_('UsageQuota|Usage Quotas')
......@@ -16,9 +17,16 @@
%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')
- 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-pane#pipelines-quota-tab
= render "namespaces/pipelines_quota/list",
locals: { namespace: @group, projects: @projects }
.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', anchor: 'storage-usage-quota'), 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', anchor: 'storage-usage-quota'), purchase_storage_url: url_to_purchase_storage, is_temporary_storage_increase_visible: temporary_storage_increase_visible?(@group).to_s } }
- page_title s_("UsageQuota|Usage")
- @content_class = "limit-container-width" unless fluid_layout
- 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
= s_('UsageQuota|Usage Quotas')
......@@ -17,9 +18,16 @@
%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')
- 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-pane#pipelines-quota-tab
= render "namespaces/pipelines_quota/list",
locals: { namespace: @namespace, projects: @projects }
.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', anchor: 'storage-usage-quota'), 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', anchor: 'storage-usage-quota'), 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/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/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),
});
});
});
});
......@@ -32801,6 +32801,9 @@ msgstr ""
msgid "UsageQuota|Learn more about usage quotas"
msgstr ""
msgid "UsageQuota|Other Storage"
msgstr ""
msgid "UsageQuota|Packages"
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