diff options
Diffstat (limited to 'app/assets/javascripts/usage_quotas/storage')
8 files changed, 681 insertions, 0 deletions
diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue new file mode 100644 index 00000000000..94bc15fa0d0 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_app.vue @@ -0,0 +1,134 @@ +<script> +import { GlAlert, GlButton, GlLink, GlLoadingIcon } from '@gitlab/ui'; +import { sprintf } from '~/locale'; +import { updateRepositorySize } from '~/api/projects_api'; +import { + ERROR_MESSAGE, + LEARN_MORE_LABEL, + USAGE_QUOTAS_LABEL, + TOTAL_USAGE_TITLE, + TOTAL_USAGE_SUBTITLE, + TOTAL_USAGE_DEFAULT_TEXT, + HELP_LINK_ARIA_LABEL, + RECALCULATE_REPOSITORY_LABEL, + projectContainerRegistryPopoverContent, +} from '../constants'; +import getProjectStorageStatistics from '../queries/project_storage.query.graphql'; +import { parseGetProjectStorageResults } from '../utils'; +import UsageGraph from './usage_graph.vue'; +import ProjectStorageDetail from './project_storage_detail.vue'; + +export default { + name: 'ProjectStorageApp', + components: { + GlAlert, + GlButton, + GlLink, + GlLoadingIcon, + UsageGraph, + ProjectStorageDetail, + }, + inject: ['projectPath', 'helpLinks'], + provide: { + containerRegistryPopoverContent: projectContainerRegistryPopoverContent, + }, + apollo: { + project: { + query: getProjectStorageStatistics, + variables() { + return { + fullPath: this.projectPath, + }; + }, + update(data) { + return parseGetProjectStorageResults(data, this.helpLinks); + }, + error() { + this.error = ERROR_MESSAGE; + }, + }, + }, + data() { + return { + project: {}, + error: '', + loadingRecalculateSize: false, + }; + }, + computed: { + totalUsage() { + return this.project?.storage?.totalUsage || TOTAL_USAGE_DEFAULT_TEXT; + }, + storageTypes() { + return this.project?.storage?.storageTypes || []; + }, + }, + methods: { + clearError() { + this.error = ''; + }, + helpLinkAriaLabel(linkTitle) { + return sprintf(HELP_LINK_ARIA_LABEL, { + linkTitle, + }); + }, + async postRecalculateSize() { + const alertEl = document.querySelector('.js-recalculation-started-alert'); + + this.loadingRecalculateSize = true; + + await updateRepositorySize(this.projectPath); + + this.loadingRecalculateSize = false; + alertEl?.classList.remove('gl-display-none'); + }, + }, + LEARN_MORE_LABEL, + USAGE_QUOTAS_LABEL, + TOTAL_USAGE_TITLE, + TOTAL_USAGE_SUBTITLE, + RECALCULATE_REPOSITORY_LABEL, +}; +</script> +<template> + <gl-loading-icon v-if="$apollo.queries.project.loading" class="gl-mt-5" size="lg" /> + <gl-alert v-else-if="error" variant="danger" @dismiss="clearError"> + {{ error }} + </gl-alert> + <div v-else> + <div class="gl-pt-5 gl-px-3"> + <div class="gl-display-flex gl-justify-content-space-between gl-align-items-center"> + <div> + <p class="gl-m-0 gl-font-lg gl-font-weight-bold">{{ $options.TOTAL_USAGE_TITLE }}</p> + <p class="gl-m-0 gl-text-gray-400"> + {{ $options.TOTAL_USAGE_SUBTITLE }} + <gl-link + :href="helpLinks.usageQuotas" + target="_blank" + :aria-label="helpLinkAriaLabel($options.USAGE_QUOTAS_LABEL)" + data-testid="usage-quotas-help-link" + > + {{ $options.LEARN_MORE_LABEL }} + </gl-link> + </p> + </div> + <p class="gl-m-0 gl-font-size-h-display gl-font-weight-bold" data-testid="total-usage"> + {{ totalUsage }} + </p> + </div> + </div> + <div v-if="project.statistics" class="gl-w-full"> + <usage-graph :root-storage-statistics="project.statistics" :limit="0" /> + </div> + <div class="gl-w-full gl-my-5"> + <gl-button + :loading="loadingRecalculateSize" + category="secondary" + @click="postRecalculateSize" + > + {{ $options.RECALCULATE_REPOSITORY_LABEL }} + </gl-button> + </div> + <project-storage-detail :storage-types="storageTypes" /> + </div> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue new file mode 100644 index 00000000000..2b97886e650 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/project_storage_detail.vue @@ -0,0 +1,142 @@ +<script> +import { GlIcon, GlLink, GlSprintf, GlTableLite, GlPopover } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { thWidthPercent } from '~/lib/utils/table_utility'; +import { sprintf } from '~/locale'; +import { + HELP_LINK_ARIA_LABEL, + PROJECT_TABLE_LABEL_STORAGE_TYPE, + PROJECT_TABLE_LABEL_USAGE, + containerRegistryId, + containerRegistryPopoverId, + uploadsId, + uploadsPopoverId, + uploadsPopoverContent, +} from '../constants'; +import { descendingStorageUsageSort } from '../utils'; +import StorageTypeIcon from './storage_type_icon.vue'; + +export default { + name: 'ProjectStorageDetail', + components: { + GlLink, + GlIcon, + GlTableLite, + GlSprintf, + StorageTypeIcon, + GlPopover, + }, + inject: ['containerRegistryPopoverContent'], + props: { + storageTypes: { + type: Array, + required: true, + }, + }, + computed: { + sizeSortedStorageTypes() { + const warnings = { + [containerRegistryId]: { + popoverId: containerRegistryPopoverId, + popoverContent: this.containerRegistryPopoverContent, + }, + [uploadsId]: { + popoverId: uploadsPopoverId, + popoverContent: this.$options.i18n.uploadsPopoverContent, + }, + }; + + return this.storageTypes + .map((type) => { + const warning = warnings[type.storageType.id] || null; + return { + warning, + ...type, + }; + }) + .sort(descendingStorageUsageSort('value')); + }, + }, + methods: { + helpLinkAriaLabel(linkTitle) { + return sprintf(HELP_LINK_ARIA_LABEL, { + linkTitle, + }); + }, + numberToHumanSize, + }, + projectTableFields: [ + { + key: 'storageType', + label: PROJECT_TABLE_LABEL_STORAGE_TYPE, + thClass: thWidthPercent(90), + }, + { + key: 'value', + label: PROJECT_TABLE_LABEL_USAGE, + thClass: thWidthPercent(10), + }, + ], + i18n: { + uploadsPopoverContent, + }, +}; +</script> +<template> + <gl-table-lite :items="sizeSortedStorageTypes" :fields="$options.projectTableFields"> + <template #cell(storageType)="{ item }"> + <div class="gl-display-flex gl-flex-direction-row"> + <storage-type-icon + :name="item.storageType.id" + :data-testid="`${item.storageType.id}-icon`" + /> + <div> + <p class="gl-font-weight-bold gl-mb-0" :data-testid="`${item.storageType.id}-name`"> + {{ item.storageType.name }} + <gl-link + v-if="item.storageType.helpPath" + :href="item.storageType.helpPath" + target="_blank" + :aria-label="helpLinkAriaLabel(item.storageType.name)" + :data-testid="`${item.storageType.id}-help-link`" + > + <gl-icon name="question" :size="12" /> + </gl-link> + </p> + <p class="gl-mb-0" :data-testid="`${item.storageType.id}-description`"> + {{ item.storageType.description }} + </p> + <p v-if="item.storageType.warningMessage" class="gl-mb-0 gl-font-sm"> + <gl-icon name="warning" :size="12" /> + <gl-sprintf :message="item.storageType.warningMessage"> + <template #warningLink="{ content }"> + <gl-link :href="item.storageType.warningLink" target="_blank" class="gl-font-sm">{{ + content + }}</gl-link> + </template> + </gl-sprintf> + </p> + </div> + </div> + </template> + + <template #cell(value)="{ item }"> + {{ numberToHumanSize(item.value, 1) }} + + <template v-if="item.warning"> + <gl-icon + :id="item.warning.popoverId" + name="warning" + class="gl-mt-2 gl-lg-mt-0 gl-lg-ml-2" + /> + <gl-popover + triggers="hover focus" + placement="top" + :target="item.warning.popoverId" + :content="item.warning.popoverContent" + :data-testid="item.warning.popoverId" + /> + </template> + </template> + </gl-table-lite> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue new file mode 100644 index 00000000000..bc7cd42df1e --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/storage_type_icon.vue @@ -0,0 +1,35 @@ +<script> +import { GlIcon } from '@gitlab/ui'; + +export default { + components: { GlIcon }, + props: { + name: { + type: String, + required: false, + default: '', + }, + }, + methods: { + iconName(storageTypeName) { + const defaultStorageTypeIcon = 'disk'; + const storageTypeIconMap = { + lfsObjectsSize: 'doc-image', + snippetsSize: 'snippet', + uploadsSize: 'upload', + repositorySize: 'infrastructure-registry', + packagesSize: 'package', + }; + + return storageTypeIconMap[`${storageTypeName}`] ?? defaultStorageTypeIcon; + }, + }, +}; +</script> +<template> + <span + class="gl-display-inline-flex gl-align-items-flex-start gl-justify-content-center gl-min-w-8 gl-pr-2 gl-pt-1" + > + <gl-icon :name="iconName(name)" :size="16" class="gl-mt-1" /> + </span> +</template> diff --git a/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue new file mode 100644 index 00000000000..7e001685060 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/components/usage_graph.vue @@ -0,0 +1,170 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { PROJECT_STORAGE_TYPES } from '../constants'; +import { descendingStorageUsageSort } from '../utils'; + +export default { + components: { + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [glFeatureFlagMixin()], + props: { + rootStorageStatistics: { + required: true, + type: Object, + }, + limit: { + required: true, + type: Number, + }, + }, + computed: { + storageTypes() { + const { + containerRegistrySize, + buildArtifactsSize, + pipelineArtifactsSize, + lfsObjectsSize, + packagesSize, + repositorySize, + storageSize, + wikiSize, + snippetsSize, + uploadsSize, + } = this.rootStorageStatistics; + const artifactsSize = buildArtifactsSize + pipelineArtifactsSize; + + if (storageSize === 0) { + return null; + } + + return [ + { + id: 'repositorySize', + style: this.usageStyle(this.barRatio(repositorySize)), + class: 'gl-bg-data-viz-blue-500', + size: repositorySize, + }, + { + id: 'lfsObjectsSize', + style: this.usageStyle(this.barRatio(lfsObjectsSize)), + class: 'gl-bg-data-viz-orange-600', + size: lfsObjectsSize, + }, + { + id: 'packagesSize', + style: this.usageStyle(this.barRatio(packagesSize)), + class: 'gl-bg-data-viz-aqua-500', + size: packagesSize, + }, + { + id: 'containerRegistrySize', + style: this.usageStyle(this.barRatio(containerRegistrySize)), + class: 'gl-bg-data-viz-aqua-800', + size: containerRegistrySize, + }, + { + id: 'buildArtifactsSize', + style: this.usageStyle(this.barRatio(artifactsSize)), + class: 'gl-bg-data-viz-green-600', + size: artifactsSize, + }, + { + id: 'wikiSize', + style: this.usageStyle(this.barRatio(wikiSize)), + class: 'gl-bg-data-viz-magenta-500', + size: wikiSize, + }, + { + id: 'snippetsSize', + style: this.usageStyle(this.barRatio(snippetsSize)), + class: 'gl-bg-data-viz-orange-800', + size: snippetsSize, + }, + { + id: 'uploadsSize', + style: this.usageStyle(this.barRatio(uploadsSize)), + class: 'gl-bg-data-viz-aqua-700', + size: uploadsSize, + }, + ] + .filter((data) => data.size !== 0) + .sort(descendingStorageUsageSort('size')) + .map((storageType) => { + const storageTypeExtraData = PROJECT_STORAGE_TYPES.find( + (type) => storageType.id === type.id, + ); + const { name, tooltip } = storageTypeExtraData || {}; + + return { + name, + tooltip, + ...storageType, + }; + }); + }, + }, + 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 gl-mb-4"> + <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" + data-qa-selector="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> diff --git a/app/assets/javascripts/usage_quotas/storage/constants.js b/app/assets/javascripts/usage_quotas/storage/constants.js new file mode 100644 index 00000000000..fab18cefc60 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/constants.js @@ -0,0 +1,100 @@ +import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; + +export const ERROR_MESSAGE = s__( + 'UsageQuota|Something went wrong while fetching project storage statistics', +); +export const LEARN_MORE_LABEL = __('Learn more.'); +export const USAGE_QUOTAS_LABEL = s__('UsageQuota|Usage Quotas'); +export const TOTAL_USAGE_TITLE = s__('UsageQuota|Usage breakdown'); +export const TOTAL_USAGE_SUBTITLE = s__( + 'UsageQuota|Includes artifacts, repositories, wiki, uploads, and other items.', +); +export const TOTAL_USAGE_DEFAULT_TEXT = __('Not applicable.'); +export const HELP_LINK_ARIA_LABEL = s__('UsageQuota|%{linkTitle} help link'); +export const RECALCULATE_REPOSITORY_LABEL = s__('UsageQuota|Recalculate repository usage'); + +export const projectContainerRegistryPopoverContent = s__( + 'UsageQuotas|The project-level storage statistics for the Container Registry are directional only and do not include savings for instance-wide deduplication.', +); + +export const containerRegistryId = 'containerRegistrySize'; +export const containerRegistryPopoverId = 'container-registry-popover'; +export const uploadsId = 'uploadsSize'; +export const uploadsPopoverId = 'uploads-popover'; +export const uploadsPopoverContent = s__( + 'NamespaceStorage|Uploads are not counted in namespace storage quotas.', +); + +export const PROJECT_TABLE_LABEL_PROJECT = __('Project'); +export const PROJECT_TABLE_LABEL_STORAGE_TYPE = s__('UsageQuota|Storage type'); +export const PROJECT_TABLE_LABEL_USAGE = s__('UsageQuota|Usage'); + +export const PROJECT_STORAGE_TYPES = [ + { + id: 'containerRegistrySize', + name: s__('UsageQuota|Container Registry'), + description: s__( + 'UsageQuota|Gitlab-integrated Docker Container Registry for storing Docker Images.', + ), + }, + { + id: 'buildArtifactsSize', + name: s__('UsageQuota|Artifacts'), + description: s__('UsageQuota|Pipeline artifacts and job artifacts, created with CI/CD.'), + tooltip: s__('UsageQuota|Artifacts is a sum of build and pipeline artifacts.'), + }, + { + id: 'lfsObjectsSize', + name: s__('UsageQuota|LFS storage'), + description: s__('UsageQuota|Audio samples, videos, datasets, and graphics.'), + }, + { + id: 'packagesSize', + name: s__('UsageQuota|Packages'), + description: s__('UsageQuota|Code packages and container images.'), + }, + { + id: 'repositorySize', + name: s__('UsageQuota|Repository'), + description: s__('UsageQuota|Git repository.'), + }, + { + id: 'snippetsSize', + name: s__('UsageQuota|Snippets'), + description: s__('UsageQuota|Shared bits of code and text.'), + }, + { + id: 'uploadsSize', + name: s__('UsageQuota|Uploads'), + description: s__('UsageQuota|File attachments and smaller design graphics.'), + }, + { + id: 'wikiSize', + name: s__('UsageQuota|Wiki'), + description: s__('UsageQuota|Wiki content.'), + }, +]; + +export const projectHelpPaths = { + containerRegistry: helpPagePath( + 'user/packages/container_registry/reduce_container_registry_storage', + ), + usageQuotas: helpPagePath('user/usage_quotas'), + usageQuotasNamespaceStorageLimit: helpPagePath('user/usage_quotas', { + anchor: 'namespace-storage-limit', + }), + buildArtifacts: helpPagePath('ci/pipelines/job_artifacts', { + anchor: 'when-job-artifacts-are-deleted', + }), + packages: helpPagePath('user/packages/package_registry/index.md', { + anchor: 'reduce-storage-usage', + }), + repository: helpPagePath('user/project/repository/reducing_the_repo_size_using_git'), + snippets: helpPagePath('user/snippets', { + anchor: 'reduce-snippets-repository-size', + }), + wiki: helpPagePath('administration/wikis/index.md', { + anchor: 'reduce-wiki-repository-size', + }), +}; diff --git a/app/assets/javascripts/usage_quotas/storage/init_project_storage.js b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js new file mode 100644 index 00000000000..00cb274902d --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/init_project_storage.js @@ -0,0 +1,34 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { projectHelpPaths as helpLinks } from './constants'; +import ProjectStorageApp from './components/project_storage_app.vue'; + +Vue.use(VueApollo); + +export default (containerId = 'js-project-storage-count-app') => { + const el = document.getElementById(containerId); + + if (!el) { + return false; + } + + const { projectPath } = el.dataset; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + return new Vue({ + el, + apolloProvider, + name: 'ProjectStorageApp', + provide: { + projectPath, + helpLinks, + }, + render(createElement) { + return createElement(ProjectStorageApp); + }, + }); +}; diff --git a/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql new file mode 100644 index 00000000000..6637e5e0865 --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/queries/project_storage.query.graphql @@ -0,0 +1,17 @@ +query getProjectStorageStatistics($fullPath: ID!) { + project(fullPath: $fullPath) { + id + statistics { + containerRegistrySize + buildArtifactsSize + pipelineArtifactsSize + lfsObjectsSize + packagesSize + repositorySize + snippetsSize + storageSize + uploadsSize + wikiSize + } + } +} diff --git a/app/assets/javascripts/usage_quotas/storage/utils.js b/app/assets/javascripts/usage_quotas/storage/utils.js new file mode 100644 index 00000000000..443788f650d --- /dev/null +++ b/app/assets/javascripts/usage_quotas/storage/utils.js @@ -0,0 +1,49 @@ +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { PROJECT_STORAGE_TYPES } from './constants'; + +export const getStorageTypesFromProjectStatistics = (projectStatistics, helpLinks = {}) => + PROJECT_STORAGE_TYPES.reduce((types, currentType) => { + const helpPathKey = currentType.id.replace(`Size`, ``); + const helpPath = helpLinks[helpPathKey]; + + return types.concat({ + storageType: { + ...currentType, + helpPath, + }, + value: projectStatistics[currentType.id], + }); + }, []); + +/** + * This method parses the results from `getProjectStorageStatistics` call. + * + * @param {Object} data graphql result + * @returns {Object} + */ +export const parseGetProjectStorageResults = (data, helpLinks) => { + const projectStatistics = data?.project?.statistics; + if (!projectStatistics) { + return {}; + } + const { storageSize } = projectStatistics; + const storageTypes = getStorageTypesFromProjectStatistics(projectStatistics, helpLinks); + + return { + storage: { + totalUsage: numberToHumanSize(storageSize, 1), + storageTypes, + }, + statistics: projectStatistics, + }; +}; + +/** + * Creates a sorting function to sort storage types by usage in the graph and in the table + * + * @param {string} storageUsageKey key storing value of storage usage + * @returns {Function} sorting function + */ +export function descendingStorageUsageSort(storageUsageKey) { + return (a, b) => b[storageUsageKey] - a[storageUsageKey]; +} |