summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/packages_and_registries
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/packages_and_registries')
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue56
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue75
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue70
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue109
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue164
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue44
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue38
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue50
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue179
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue256
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue34
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue71
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue71
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue35
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue54
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue153
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue111
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue110
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue51
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js4
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js166
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js15
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js5
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js53
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js14
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql9
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql5
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql41
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql22
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql29
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql6
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js121
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue233
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue5
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue409
-rw-r--r--app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js35
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue87
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue49
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue52
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql19
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue86
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue62
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue116
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue2
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue10
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue100
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue6
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/constants.js63
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js1
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql31
-rw-r--r--app/assets/javascripts/packages_and_registries/package_registry/utils.js8
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue1
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js7
-rw-r--r--app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js7
57 files changed, 3452 insertions, 175 deletions
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
new file mode 100644
index 00000000000..e4a1a1a8266
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_button.vue
@@ -0,0 +1,56 @@
+<script>
+import { GlTooltipDirective, GlButton } from '@gitlab/ui';
+
+export default {
+ name: 'DeleteButton',
+ components: {
+ GlButton,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ title: {
+ type: String,
+ required: true,
+ },
+ tooltipTitle: {
+ type: String,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ tooltipDisabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ tooltipConfiguration() {
+ return {
+ disabled: this.tooltipDisabled,
+ title: this.tooltipTitle,
+ };
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-tooltip="tooltipConfiguration">
+ <gl-button
+ v-gl-tooltip
+ :disabled="disabled"
+ :title="title"
+ :aria-label="title"
+ variant="danger"
+ category="secondary"
+ icon="remove"
+ @click="$emit('delete')"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue
new file mode 100644
index 00000000000..a313854f5e4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/delete_image.vue
@@ -0,0 +1,75 @@
+<script>
+import { produce } from 'immer';
+import { GRAPHQL_PAGE_SIZE } from '../constants/index';
+import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
+import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
+
+export default {
+ props: {
+ id: {
+ type: String,
+ required: false,
+ default: null,
+ },
+ useUpdateFn: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ methods: {
+ updateImageStatus(store, { data: { destroyContainerRepository } }) {
+ const variables = {
+ id: this.id,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ const sourceData = store.readQuery({
+ query: getContainerRepositoryDetailsQuery,
+ variables,
+ });
+
+ const data = produce(sourceData, (draftState) => {
+ draftState.containerRepository.status =
+ destroyContainerRepository.containerRepository.status;
+ });
+
+ store.writeQuery({
+ query: getContainerRepositoryDetailsQuery,
+ variables,
+ data,
+ });
+ },
+ doDelete() {
+ this.$emit('start');
+ return this.$apollo
+ .mutate({
+ mutation: deleteContainerRepositoryMutation,
+ variables: {
+ id: this.id,
+ },
+ update: this.useUpdateFn ? this.updateImageStatus : undefined,
+ })
+ .then(({ data }) => {
+ if (data?.destroyContainerRepository?.errors[0]) {
+ this.$emit('error', data?.destroyContainerRepository?.errors);
+ return;
+ }
+ this.$emit('success');
+ })
+ .catch((e) => {
+ // note: we are adding an array to follow the same format of the error raised above
+ this.$emit('error', [e]);
+ })
+ .finally(() => {
+ this.$emit('end');
+ });
+ },
+ },
+ render() {
+ if (this.$scopedSlots?.default) {
+ return this.$scopedSlots.default({ doDelete: this.doDelete });
+ }
+ return null;
+ },
+};
+</script>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
new file mode 100644
index 00000000000..56d2ff86fb7
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue
@@ -0,0 +1,70 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { ALERT_MESSAGES, ADMIN_GARBAGE_COLLECTION_TIP } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ model: {
+ prop: 'deleteAlertType',
+ event: 'change',
+ },
+ props: {
+ deleteAlertType: {
+ type: String,
+ default: null,
+ required: false,
+ validator(value) {
+ return !value || ALERT_MESSAGES[value] !== undefined;
+ },
+ },
+ garbageCollectionHelpPagePath: { type: String, required: false, default: '' },
+ isAdmin: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ deleteAlertConfig() {
+ const config = {
+ title: '',
+ message: '',
+ type: 'success',
+ };
+ if (this.deleteAlertType) {
+ [config.type] = this.deleteAlertType.split('_');
+
+ config.message = ALERT_MESSAGES[this.deleteAlertType];
+
+ if (this.isAdmin && config.type === 'success') {
+ config.title = config.message;
+ config.message = ADMIN_GARBAGE_COLLECTION_TIP;
+ }
+ }
+ return config;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-alert
+ v-if="deleteAlertType"
+ :variant="deleteAlertConfig.type"
+ :title="deleteAlertConfig.title"
+ @dismiss="$emit('change', null)"
+ >
+ <gl-sprintf :message="deleteAlertConfig.message">
+ <template #docLink="{ content }">
+ <gl-link :href="garbageCollectionHelpPagePath" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
new file mode 100644
index 00000000000..f857c96c9d1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue
@@ -0,0 +1,109 @@
+<script>
+import { GlModal, GlSprintf, GlFormInput } from '@gitlab/ui';
+import { n__ } from '~/locale';
+import {
+ REMOVE_TAG_CONFIRMATION_TEXT,
+ REMOVE_TAGS_CONFIRMATION_TEXT,
+ DELETE_IMAGE_CONFIRMATION_TITLE,
+ DELETE_IMAGE_CONFIRMATION_TEXT,
+} from '../../constants';
+
+export default {
+ components: {
+ GlModal,
+ GlSprintf,
+ GlFormInput,
+ },
+ props: {
+ itemsToBeDeleted: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ deleteImage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ projectPath: '',
+ };
+ },
+ computed: {
+ imageProjectPath() {
+ return this.itemsToBeDeleted[0]?.project?.path;
+ },
+ modalTitle() {
+ if (this.deleteImage) {
+ return DELETE_IMAGE_CONFIRMATION_TITLE;
+ }
+ return n__(
+ 'ContainerRegistry|Remove tag',
+ 'ContainerRegistry|Remove tags',
+ this.itemsToBeDeleted.length,
+ );
+ },
+ modalDescription() {
+ if (this.deleteImage) {
+ return {
+ message: DELETE_IMAGE_CONFIRMATION_TEXT,
+ item: this.imageProjectPath,
+ };
+ }
+ if (this.itemsToBeDeleted.length > 1) {
+ return {
+ message: REMOVE_TAGS_CONFIRMATION_TEXT,
+ item: this.itemsToBeDeleted.length,
+ };
+ }
+
+ const [first] = this.itemsToBeDeleted;
+ return {
+ message: REMOVE_TAG_CONFIRMATION_TEXT,
+ item: first?.path,
+ };
+ },
+ disablePrimaryButton() {
+ return this.deleteImage && this.projectPath !== this.imageProjectPath;
+ },
+ },
+ methods: {
+ show() {
+ this.$refs.deleteModal.show();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-tag-modal"
+ ok-variant="danger"
+ :action-primary="{
+ text: __('Delete'),
+ attributes: [{ variant: 'danger' }, { disabled: disablePrimaryButton }],
+ }"
+ :action-cancel="{ text: __('Cancel') }"
+ @primary="$emit('confirmDelete')"
+ @cancel="$emit('cancelDelete')"
+ @change="projectPath = ''"
+ >
+ <template #modal-title>{{ modalTitle }}</template>
+ <p v-if="modalDescription" data-testid="description">
+ <gl-sprintf :message="modalDescription.message">
+ <template #item>
+ <b>{{ modalDescription.item }}</b>
+ </template>
+ <template #code>
+ <code>{{ modalDescription.item }}</code>
+ </template>
+ </gl-sprintf>
+ </p>
+ <div v-if="deleteImage">
+ <gl-form-input v-model="projectPath" />
+ </div>
+ </gl-modal>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
new file mode 100644
index 00000000000..e9e36151fe6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue
@@ -0,0 +1,164 @@
+<script>
+import { GlIcon, GlTooltipDirective, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { sprintf, n__, s__ } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import timeagoMixin from '~/vue_shared/mixins/timeago';
+import {
+ UPDATED_AT,
+ CLEANUP_UNSCHEDULED_TEXT,
+ CLEANUP_SCHEDULED_TEXT,
+ CLEANUP_ONGOING_TEXT,
+ CLEANUP_UNFINISHED_TEXT,
+ CLEANUP_DISABLED_TEXT,
+ CLEANUP_SCHEDULED_TOOLTIP,
+ CLEANUP_ONGOING_TOOLTIP,
+ CLEANUP_UNFINISHED_TOOLTIP,
+ CLEANUP_DISABLED_TOOLTIP,
+ UNFINISHED_STATUS,
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
+} from '../../constants/index';
+
+import getContainerRepositoryTagsCountQuery from '../../graphql/queries/get_container_repository_tags_count.query.graphql';
+
+export default {
+ name: 'DetailsHeader',
+ components: { GlIcon, TitleArea, MetadataItem, GlDropdown, GlDropdownItem },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [timeagoMixin],
+ props: {
+ image: {
+ type: Object,
+ required: true,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ data() {
+ return {
+ containerRepository: {},
+ fetchTagsCount: false,
+ };
+ },
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsCountQuery,
+ variables() {
+ return {
+ id: this.image.id,
+ };
+ },
+ },
+ },
+ computed: {
+ imageDetails() {
+ return { ...this.image, ...this.containerRepository };
+ },
+ visibilityIcon() {
+ return this.imageDetails?.project?.visibility === 'public' ? 'eye' : 'eye-slash';
+ },
+ timeAgo() {
+ return this.timeFormatted(this.imageDetails.updatedAt);
+ },
+ updatedText() {
+ return sprintf(UPDATED_AT, { time: this.timeAgo });
+ },
+ tagCountText() {
+ if (this.$apollo.queries.containerRepository.loading) {
+ return s__('ContainerRegistry|-- tags');
+ }
+ return n__('%d tag', '%d tags', this.imageDetails.tagsCount);
+ },
+ cleanupTextAndTooltip() {
+ if (!this.imageDetails.project.containerExpirationPolicy?.enabled) {
+ return { text: CLEANUP_DISABLED_TEXT, tooltip: CLEANUP_DISABLED_TOOLTIP };
+ }
+ return {
+ [UNSCHEDULED_STATUS]: {
+ text: sprintf(CLEANUP_UNSCHEDULED_TEXT, {
+ time: this.timeFormatted(this.imageDetails.project.containerExpirationPolicy.nextRunAt),
+ }),
+ },
+ [SCHEDULED_STATUS]: { text: CLEANUP_SCHEDULED_TEXT, tooltip: CLEANUP_SCHEDULED_TOOLTIP },
+ [ONGOING_STATUS]: { text: CLEANUP_ONGOING_TEXT, tooltip: CLEANUP_ONGOING_TOOLTIP },
+ [UNFINISHED_STATUS]: { text: CLEANUP_UNFINISHED_TEXT, tooltip: CLEANUP_UNFINISHED_TOOLTIP },
+ }[this.imageDetails?.expirationPolicyCleanupStatus];
+ },
+ deleteButtonDisabled() {
+ return this.disabled || !this.imageDetails.canDelete;
+ },
+ rootImageTooltip() {
+ return !this.imageDetails.name ? ROOT_IMAGE_TOOLTIP : '';
+ },
+ imageName() {
+ return this.imageDetails.name || ROOT_IMAGE_TEXT;
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area>
+ <template #title>
+ <span data-testid="title">
+ {{ imageName }}
+ </span>
+ <gl-icon
+ v-if="rootImageTooltip"
+ v-gl-tooltip="rootImageTooltip"
+ class="gl-text-blue-600"
+ name="information-o"
+ :aria-label="rootImageTooltip"
+ />
+ </template>
+ <template #metadata-tags-count>
+ <metadata-item icon="tag" :text="tagCountText" data-testid="tags-count" />
+ </template>
+
+ <template #metadata-cleanup>
+ <metadata-item
+ icon="expire"
+ :text="cleanupTextAndTooltip.text"
+ :text-tooltip="cleanupTextAndTooltip.tooltip"
+ size="xl"
+ data-testid="cleanup"
+ />
+ </template>
+
+ <template #metadata-updated>
+ <metadata-item
+ :icon="visibilityIcon"
+ :text="updatedText"
+ size="xl"
+ data-testid="updated-and-visibility"
+ />
+ </template>
+ <template #right-actions>
+ <gl-dropdown
+ icon="ellipsis_v"
+ text="More actions"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ >
+ <gl-dropdown-item
+ variant="danger"
+ :disabled="deleteButtonDisabled"
+ @click="$emit('delete')"
+ >
+ {{ __('Delete image repository') }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
new file mode 100644
index 00000000000..a16d95a6b30
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlEmptyState } from '@gitlab/ui';
+import {
+ NO_TAGS_TITLE,
+ NO_TAGS_MESSAGE,
+ MISSING_OR_DELETED_IMAGE_TITLE,
+ MISSING_OR_DELETED_IMAGE_MESSAGE,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlEmptyState,
+ },
+ props: {
+ noContainersImage: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ isEmptyImage: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ computed: {
+ title() {
+ return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_TITLE : NO_TAGS_TITLE;
+ },
+ description() {
+ return this.isEmptyImage ? MISSING_OR_DELETED_IMAGE_MESSAGE : NO_TAGS_MESSAGE;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-empty-state
+ :title="title"
+ :svg-path="noContainersImage"
+ :description="description"
+ class="gl-mx-auto gl-my-0"
+ />
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
new file mode 100644
index 00000000000..12095655126
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue
@@ -0,0 +1,38 @@
+<script>
+import { GlSprintf, GlAlert, GlLink } from '@gitlab/ui';
+
+import { DELETE_ALERT_TITLE, DELETE_ALERT_LINK_TEXT } from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlAlert,
+ GlLink,
+ },
+ props: {
+ runCleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ cleanupPoliciesHelpPagePath: { type: String, required: false, default: '' },
+ },
+ i18n: {
+ DELETE_ALERT_TITLE,
+ DELETE_ALERT_LINK_TEXT,
+ },
+};
+</script>
+
+<template>
+ <gl-alert variant="warning" :title="$options.i18n.DELETE_ALERT_TITLE" @dismiss="$emit('dismiss')">
+ <gl-sprintf :message="$options.i18n.DELETE_ALERT_LINK_TEXT">
+ <template #adminLink="{ content }">
+ <gl-link data-testid="run-link" :href="runCleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ <template #docLink="{ content }">
+ <gl-link data-testid="help-link" :href="cleanupPoliciesHelpPagePath" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
new file mode 100644
index 00000000000..fc1504f6c31
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue
@@ -0,0 +1,50 @@
+<script>
+import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
+import {
+ IMAGE_STATUS_MESSAGES,
+ IMAGE_STATUS_TITLES,
+ IMAGE_STATUS_ALERT_TYPE,
+ PACKAGE_DELETE_HELP_PAGE_PATH,
+} from '../../constants';
+
+export default {
+ components: {
+ GlAlert,
+ GlSprintf,
+ GlLink,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ message() {
+ return IMAGE_STATUS_MESSAGES[this.status];
+ },
+ title() {
+ return IMAGE_STATUS_TITLES[this.status];
+ },
+ variant() {
+ return IMAGE_STATUS_ALERT_TYPE[this.status];
+ },
+ },
+ links: {
+ PACKAGE_DELETE_HELP_PAGE_PATH,
+ },
+};
+</script>
+<template>
+ <gl-alert :title="title" :variant="variant">
+ <span data-testid="message">
+ <gl-sprintf :message="message">
+ <template #link="{ content }">
+ <gl-link :href="$options.links.PACKAGE_DELETE_HELP_PAGE_PATH" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </span>
+ </gl-alert>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
new file mode 100644
index 00000000000..3e19a646f53
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue
@@ -0,0 +1,179 @@
+<script>
+import { GlButton, GlKeysetPagination } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { joinPaths } from '~/lib/utils/url_utility';
+import {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+} from '../../constants/index';
+import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
+import EmptyState from './empty_state.vue';
+import TagsListRow from './tags_list_row.vue';
+import TagsLoader from './tags_loader.vue';
+
+export default {
+ name: 'TagsList',
+ components: {
+ GlButton,
+ GlKeysetPagination,
+ TagsListRow,
+ EmptyState,
+ TagsLoader,
+ },
+ inject: ['config'],
+ props: {
+ id: {
+ type: [Number, String],
+ required: true,
+ },
+ isMobile: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ isImageLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAGS_BUTTON_TITLE,
+ TAGS_LIST_TITLE,
+ },
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryTagsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
+ data() {
+ return {
+ selectedItems: {},
+ containerRepository: {},
+ };
+ },
+ computed: {
+ tags() {
+ return this.containerRepository?.tags?.nodes || [];
+ },
+ tagsPageInfo() {
+ return this.containerRepository?.tags?.pageInfo;
+ },
+ queryVariables() {
+ return {
+ id: joinPaths(this.config.gidPrefix, `${this.id}`),
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
+ hasSelectedItems() {
+ return this.tags.some((tag) => this.selectedItems[tag.name]);
+ },
+ showMultiDeleteButton() {
+ return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
+ },
+ multiDeleteButtonIsDisabled() {
+ return !this.hasSelectedItems || this.disabled;
+ },
+ showPagination() {
+ return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
+ },
+ hasNoTags() {
+ return this.tags.length === 0;
+ },
+ isLoading() {
+ return this.isImageLoading || this.$apollo.queries.containerRepository.loading;
+ },
+ },
+ methods: {
+ updateSelectedItems(name) {
+ this.$set(this.selectedItems, name, !this.selectedItems[name]);
+ },
+ mapTagsToBeDleeted(items) {
+ return this.tags.filter((tag) => items[tag.name]);
+ },
+ fetchNextPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ after: this.tagsPageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
+ fetchPreviousPage() {
+ this.$apollo.queries.containerRepository.fetchMore({
+ variables: {
+ first: null,
+ before: this.tagsPageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ },
+ updateQuery(previousResult, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <tags-loader v-if="isLoading" />
+ <template v-else>
+ <empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
+ <template v-else>
+ <div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
+ <h5 data-testid="list-title">
+ {{ $options.i18n.TAGS_LIST_TITLE }}
+ </h5>
+
+ <gl-button
+ v-if="showMultiDeleteButton"
+ :disabled="multiDeleteButtonIsDisabled"
+ category="secondary"
+ variant="danger"
+ @click="$emit('delete', mapTagsToBeDleeted(selectedItems))"
+ >
+ {{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
+ </gl-button>
+ </div>
+ <tags-list-row
+ v-for="(tag, index) in tags"
+ :key="tag.path"
+ :tag="tag"
+ :first="index === 0"
+ :selected="selectedItems[tag.name]"
+ :is-mobile="isMobile"
+ :disabled="disabled"
+ @select="updateSelectedItems(tag.name)"
+ @delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))"
+ />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="tagsPageInfo.hasNextPage"
+ :has-previous-page="tagsPageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="fetchPreviousPage"
+ @next="fetchNextPage"
+ />
+ </div>
+ </template>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
new file mode 100644
index 00000000000..0556fd298aa
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue
@@ -0,0 +1,256 @@
+<script>
+import {
+ GlFormCheckbox,
+ GlTooltipDirective,
+ GlSprintf,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+} from '@gitlab/ui';
+import { formatDate } from '~/lib/utils/datetime_utility';
+import { numberToHumanSize } from '~/lib/utils/number_utils';
+import { n__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+ MORE_ACTIONS_TEXT,
+} from '../../constants/index';
+
+export default {
+ components: {
+ GlSprintf,
+ GlFormCheckbox,
+ GlIcon,
+ GlDropdown,
+ GlDropdownItem,
+ ListItem,
+ ClipboardButton,
+ TimeAgoTooltip,
+ DetailsRow,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ tag: {
+ type: Object,
+ required: true,
+ },
+ isMobile: {
+ type: Boolean,
+ default: true,
+ required: false,
+ },
+ selected: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ disabled: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ REMOVE_TAG_BUTTON_TITLE,
+ DIGEST_LABEL,
+ CREATED_AT_LABEL,
+ PUBLISHED_DETAILS_ROW_TEXT,
+ MANIFEST_DETAILS_ROW_TEST,
+ CONFIGURATION_DETAILS_ROW_TEST,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ MORE_ACTIONS_TEXT,
+ },
+ computed: {
+ formattedSize() {
+ return this.tag.totalSize
+ ? numberToHumanSize(Number(this.tag.totalSize))
+ : NOT_AVAILABLE_SIZE;
+ },
+ layers() {
+ return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
+ },
+ mobileClasses() {
+ return this.isMobile ? 'mw-s' : '';
+ },
+ shortDigest() {
+ // remove sha256: from the string, and show only the first 7 char
+ return this.tag.digest?.substring(7, 14) ?? NOT_AVAILABLE_TEXT;
+ },
+ publishedDate() {
+ return formatDate(this.tag.createdAt, 'isoDate');
+ },
+ publishedTime() {
+ return formatDate(this.tag.createdAt, 'hh:MM Z');
+ },
+ formattedRevision() {
+ // to be removed when API response is adjusted
+ // see https://gitlab.com/gitlab-org/gitlab/-/issues/225324
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ return `sha256:${this.tag.revision}`;
+ },
+ tagLocation() {
+ return this.tag.path?.replace(`:${this.tag.name}`, '');
+ },
+ isInvalidTag() {
+ return !this.tag.digest;
+ },
+ isCheckboxDisabled() {
+ return this.isInvalidTag || this.disabled;
+ },
+ isDeleteDisabled() {
+ return this.isInvalidTag || this.disabled || !this.tag.canDelete;
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item v-bind="$attrs" :selected="selected" :disabled="disabled">
+ <template #left-action>
+ <gl-form-checkbox
+ v-if="tag.canDelete"
+ :disabled="isCheckboxDisabled"
+ class="gl-m-0"
+ :checked="selected"
+ @change="$emit('select')"
+ />
+ </template>
+ <template #left-primary>
+ <div class="gl-display-flex gl-align-items-center">
+ <div
+ v-gl-tooltip="{ title: tag.name }"
+ data-testid="name"
+ class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
+ :class="mobileClasses"
+ >
+ {{ tag.name }}
+ </div>
+
+ <clipboard-button
+ v-if="tag.location"
+ :title="tag.location"
+ :text="tag.location"
+ category="tertiary"
+ :disabled="disabled"
+ />
+
+ <gl-icon
+ v-if="isInvalidTag"
+ v-gl-tooltip="{ title: $options.i18n.MISSING_MANIFEST_WARNING_TOOLTIP }"
+ name="warning"
+ class="gl-text-orange-500 gl-mb-2 gl-ml-2"
+ />
+ </div>
+ </template>
+
+ <template #left-secondary>
+ <span data-testid="size">
+ {{ formattedSize }}
+ <template v-if="formattedSize && layers">&middot;</template>
+ {{ layers }}
+ </span>
+ </template>
+ <template #right-primary>
+ <span data-testid="time">
+ <gl-sprintf :message="$options.i18n.CREATED_AT_LABEL">
+ <template #timeInfo>
+ <time-ago-tooltip :time="tag.createdAt" />
+ </template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-secondary>
+ <span data-testid="digest">
+ <gl-sprintf :message="$options.i18n.DIGEST_LABEL">
+ <template #imageId>{{ shortDigest }}</template>
+ </gl-sprintf>
+ </span>
+ </template>
+ <template #right-action>
+ <gl-dropdown
+ :disabled="isDeleteDisabled"
+ icon="ellipsis_v"
+ :text="$options.i18n.MORE_ACTIONS_TEXT"
+ :text-sr-only="true"
+ category="tertiary"
+ no-caret
+ right
+ :class="{ 'gl-opacity-0 gl-pointer-events-none': isDeleteDisabled }"
+ data-testid="additional-actions"
+ data-qa-selector="more_actions_menu"
+ >
+ <gl-dropdown-item
+ variant="danger"
+ data-testid="single-delete-button"
+ data-qa-selector="tag_delete_button"
+ @click="$emit('delete')"
+ >
+ {{ $options.i18n.REMOVE_TAG_BUTTON_TITLE }}
+ </gl-dropdown-item>
+ </gl-dropdown>
+ </template>
+
+ <template v-if="!isInvalidTag" #details-published>
+ <details-row icon="clock" data-testid="published-date-detail">
+ <gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
+ <template #repositoryPath>
+ <i>{{ tagLocation }}</i>
+ </template>
+ <template #time>
+ {{ publishedTime }}
+ </template>
+ <template #date>
+ {{ publishedDate }}
+ </template>
+ </gl-sprintf>
+ </details-row>
+ </template>
+ <template v-if="!isInvalidTag" #details-manifest-digest>
+ <details-row icon="log" data-testid="manifest-detail">
+ <gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
+ <template #digest>
+ {{ tag.digest }}
+ </template>
+ </gl-sprintf>
+ <clipboard-button
+ v-if="tag.digest"
+ :title="tag.digest"
+ :text="tag.digest"
+ category="tertiary"
+ size="small"
+ :disabled="disabled"
+ />
+ </details-row>
+ </template>
+ <template v-if="!isInvalidTag" #details-configuration-digest>
+ <details-row icon="cloud-gear" data-testid="configuration-detail">
+ <gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
+ <template #digest>
+ {{ formattedRevision }}
+ </template>
+ </gl-sprintf>
+ <clipboard-button
+ v-if="formattedRevision"
+ :title="formattedRevision"
+ :text="formattedRevision"
+ category="tertiary"
+ size="small"
+ :disabled="disabled"
+ />
+ </details-row>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
new file mode 100644
index 00000000000..b7afa5fba33
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlSkeletonLoader } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlSkeletonLoader,
+ },
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <rect width="15" x="0" y="12.5" height="15" rx="4" />
+ <rect width="250" x="25" y="10" height="20" rx="4" />
+ <circle cx="290" cy="20" r="10" />
+ <rect width="100" x="315" y="10" height="20" rx="4" />
+ <rect width="100" x="500" y="10" height="20" rx="4" />
+ <rect width="100" x="630" y="10" height="20" rx="4" />
+ <rect x="960" y="0" width="40" height="40" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
new file mode 100644
index 00000000000..1f52e319ad0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
+import {
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ CLEANUP_STATUS_SCHEDULED,
+ CLEANUP_STATUS_ONGOING,
+ CLEANUP_STATUS_UNFINISHED,
+ UNFINISHED_STATUS,
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+} from '../../constants/index';
+
+export default {
+ name: 'CleanupStatus',
+ components: {
+ GlIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ status: {
+ type: String,
+ required: true,
+ validator(value) {
+ return [UNFINISHED_STATUS, UNSCHEDULED_STATUS, SCHEDULED_STATUS, ONGOING_STATUS].includes(
+ value,
+ );
+ },
+ },
+ },
+ i18n: {
+ CLEANUP_STATUS_SCHEDULED,
+ CLEANUP_STATUS_ONGOING,
+ CLEANUP_STATUS_UNFINISHED,
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ },
+ computed: {
+ showStatus() {
+ return this.status !== UNSCHEDULED_STATUS;
+ },
+ failedDelete() {
+ return this.status === UNFINISHED_STATUS;
+ },
+ statusText() {
+ return this.$options.i18n[`CLEANUP_STATUS_${this.status}`];
+ },
+ expireIconClass() {
+ return this.failedDelete ? 'gl-text-orange-500' : '';
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-if="showStatus" class="gl-display-inline-flex gl-align-items-center">
+ <gl-icon name="expire" data-testid="main-icon" :class="expireIconClass" />
+ <span class="gl-mx-2">
+ {{ statusText }}
+ </span>
+ <gl-icon
+ v-if="failedDelete"
+ v-gl-tooltip="{ title: $options.i18n.CLEANUP_TIMED_OUT_ERROR_MESSAGE }"
+ :size="14"
+ class="gl-text-black-normal"
+ data-testid="extra-info"
+ name="information"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
new file mode 100644
index 00000000000..07ee3c6083b
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue
@@ -0,0 +1,71 @@
+<script>
+import { GlDropdown } from '@gitlab/ui';
+import Tracking from '~/tracking';
+import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+import {
+ QUICK_START,
+ LOGIN_COMMAND_LABEL,
+ COPY_LOGIN_TITLE,
+ BUILD_COMMAND_LABEL,
+ COPY_BUILD_TITLE,
+ PUSH_COMMAND_LABEL,
+ COPY_PUSH_TITLE,
+} from '../../constants/index';
+
+const trackingLabel = 'quickstart_dropdown';
+
+export default {
+ components: {
+ GlDropdown,
+ CodeInstruction,
+ },
+ mixins: [Tracking.mixin({ label: trackingLabel })],
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ trackingLabel,
+ i18n: {
+ QUICK_START,
+ LOGIN_COMMAND_LABEL,
+ COPY_LOGIN_TITLE,
+ BUILD_COMMAND_LABEL,
+ COPY_BUILD_TITLE,
+ PUSH_COMMAND_LABEL,
+ COPY_PUSH_TITLE,
+ },
+};
+</script>
+<template>
+ <gl-dropdown
+ :text="$options.i18n.QUICK_START"
+ variant="info"
+ right
+ @shown="track('click_dropdown')"
+ >
+ <!-- This li is used as a container since gl-dropdown produces a root ul, this mimics the functionality exposed by b-dropdown-form -->
+ <li role="presentation" class="px-2 py-1">
+ <code-instruction
+ :label="$options.i18n.LOGIN_COMMAND_LABEL"
+ :instruction="dockerLoginCommand"
+ :copy-text="$options.i18n.COPY_LOGIN_TITLE"
+ tracking-action="click_copy_login"
+ :tracking-label="$options.trackingLabel"
+ />
+
+ <code-instruction
+ :label="$options.i18n.BUILD_COMMAND_LABEL"
+ :instruction="dockerBuildCommand"
+ :copy-text="$options.i18n.COPY_BUILD_TITLE"
+ tracking-action="click_copy_build"
+ :tracking-label="$options.trackingLabel"
+ />
+
+ <code-instruction
+ class="mb-0"
+ :label="$options.i18n.PUSH_COMMAND_LABEL"
+ :instruction="dockerPushCommand"
+ :copy-text="$options.i18n.COPY_PUSH_TITLE"
+ tracking-action="click_copy_push"
+ :tracking-label="$options.trackingLabel"
+ />
+ </li>
+ </gl-dropdown>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue
new file mode 100644
index 00000000000..a68c4de5aa6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue
@@ -0,0 +1,35 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
+
+export default {
+ name: 'GroupEmptyState',
+ components: {
+ GlEmptyState,
+ GlSprintf,
+ GlLink,
+ },
+ inject: ['config'],
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images available in this group')"
+ :svg-path="config.noContainersImage"
+ >
+ <template #description>
+ <p>
+ <gl-sprintf
+ :message="
+ s__(
+ `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
+ )
+ "
+ >
+ <template #docLink="{ content }">
+ <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
new file mode 100644
index 00000000000..5bd13322ebb
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue
@@ -0,0 +1,54 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import ImageListRow from './image_list_row.vue';
+
+export default {
+ name: 'ImageList',
+ components: {
+ GlKeysetPagination,
+ ImageListRow,
+ },
+ props: {
+ images: {
+ type: Array,
+ required: true,
+ },
+ metadataLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-display-flex gl-flex-direction-column">
+ <image-list-row
+ v-for="(listItem, index) in images"
+ :key="index"
+ :item="listItem"
+ :metadata-loading="metadataLoading"
+ @delete="$emit('delete', $event)"
+ />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ :has-next-page="pageInfo.hasNextPage"
+ :has-previous-page="pageInfo.hasPreviousPage"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
new file mode 100644
index 00000000000..c1ec523574a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue
@@ -0,0 +1,153 @@
+<script>
+import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import { n__ } from '~/locale';
+
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import {
+ ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ IMAGE_FAILED_DELETED_STATUS,
+ ROOT_IMAGE_TEXT,
+} from '../../constants/index';
+import DeleteButton from '../delete_button.vue';
+import CleanupStatus from './cleanup_status.vue';
+
+export default {
+ name: 'ImageListRow',
+ components: {
+ ClipboardButton,
+ DeleteButton,
+ GlSprintf,
+ GlIcon,
+ ListItem,
+ GlSkeletonLoader,
+ CleanupStatus,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ item: {
+ type: Object,
+ required: true,
+ },
+ metadataLoading: {
+ type: Boolean,
+ default: false,
+ required: false,
+ },
+ },
+ i18n: {
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ ROW_SCHEDULED_FOR_DELETION,
+ },
+ computed: {
+ disabledDelete() {
+ return !this.item.canDelete || this.deleting;
+ },
+ id() {
+ return getIdFromGraphQLId(this.item.id);
+ },
+ deleting() {
+ return this.item.status === IMAGE_DELETE_SCHEDULED_STATUS;
+ },
+ failedDelete() {
+ return this.item.status === IMAGE_FAILED_DELETED_STATUS;
+ },
+ tagsCountText() {
+ return n__(
+ 'ContainerRegistry|%{count} Tag',
+ 'ContainerRegistry|%{count} Tags',
+ this.item.tagsCount,
+ );
+ },
+ warningIconText() {
+ if (this.failedDelete) {
+ return ASYNC_DELETE_IMAGE_ERROR_MESSAGE;
+ }
+ if (this.item.expirationPolicyStartedAt) {
+ return CLEANUP_TIMED_OUT_ERROR_MESSAGE;
+ }
+ return null;
+ },
+ imageName() {
+ return this.item.name ? this.item.path : `${this.item.path}/ ${ROOT_IMAGE_TEXT}`;
+ },
+ routerLinkEvent() {
+ return this.deleting ? '' : 'click';
+ },
+ },
+};
+</script>
+
+<template>
+ <list-item
+ v-gl-tooltip="{
+ placement: 'left',
+ disabled: !deleting,
+ title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
+ }"
+ v-bind="$attrs"
+ :disabled="deleting"
+ >
+ <template #left-primary>
+ <router-link
+ class="gl-text-body gl-font-weight-bold"
+ data-testid="details-link"
+ data-qa-selector="registry_image_content"
+ :event="routerLinkEvent"
+ :to="{ name: 'details', params: { id } }"
+ >
+ {{ imageName }}
+ </router-link>
+ <clipboard-button
+ v-if="item.location"
+ :disabled="deleting"
+ :text="item.location"
+ :title="item.location"
+ category="tertiary"
+ />
+ </template>
+ <template #left-secondary>
+ <template v-if="!metadataLoading">
+ <span class="gl-display-flex gl-align-items-center" data-testid="tags-count">
+ <gl-icon name="tag" class="gl-mr-2" />
+ <gl-sprintf :message="tagsCountText">
+ <template #count>
+ {{ item.tagsCount }}
+ </template>
+ </gl-sprintf>
+ </span>
+
+ <cleanup-status
+ v-if="item.expirationPolicyCleanupStatus"
+ class="ml-2"
+ :status="item.expirationPolicyCleanupStatus"
+ />
+ </template>
+
+ <div v-else class="gl-w-full">
+ <gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet">
+ <circle cx="6" cy="8" r="6" />
+ <rect x="16" y="4" width="100" height="8" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ </template>
+ <template #right-action>
+ <delete-button
+ :title="$options.i18n.REMOVE_REPOSITORY_LABEL"
+ :disabled="disabledDelete"
+ :tooltip-disabled="item.canDelete"
+ :tooltip-title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
+ @delete="$emit('delete', item)"
+ />
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue
new file mode 100644
index 00000000000..5aa04419ca0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue
@@ -0,0 +1,111 @@
+<script>
+import { GlEmptyState, GlSprintf, GlLink, GlFormInputGroup, GlFormInput } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import {
+ COPY_LOGIN_TITLE,
+ COPY_BUILD_TITLE,
+ COPY_PUSH_TITLE,
+ QUICK_START,
+} from '../../constants/index';
+
+export default {
+ name: 'ProjectEmptyState',
+ components: {
+ ClipboardButton,
+ GlEmptyState,
+ GlSprintf,
+ GlLink,
+ GlFormInputGroup,
+ GlFormInput,
+ },
+ inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'],
+ i18n: {
+ quickStart: QUICK_START,
+ copyLoginTitle: COPY_LOGIN_TITLE,
+ copyBuildTitle: COPY_BUILD_TITLE,
+ copyPushTitle: COPY_PUSH_TITLE,
+ introText: s__(
+ `ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`,
+ ),
+ notLoggedInMessage: s__(
+ `ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password.`,
+ ),
+ addImageText: s__(
+ 'ContainerRegistry|You can add an image to this registry with the following commands:',
+ ),
+ },
+};
+</script>
+<template>
+ <gl-empty-state
+ :title="s__('ContainerRegistry|There are no container images stored for this project')"
+ :svg-path="config.noContainersImage"
+ >
+ <template #description>
+ <p>
+ <gl-sprintf :message="$options.i18n.introText">
+ <template #docLink="{ content }">
+ <gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <h5>{{ $options.i18n.quickStart }}</h5>
+ <p>
+ <gl-sprintf :message="$options.i18n.notLoggedInMessage">
+ <template #twofaDocLink="{ content }">
+ <gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link>
+ </template>
+ <template #personalAccessTokensDocLink="{ content }">
+ <gl-link :href="config.personalAccessTokensHelpLink" target="_blank">{{
+ content
+ }}</gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-input-group class="gl-mb-4">
+ <gl-form-input
+ :value="dockerLoginCommand"
+ readonly
+ type="text"
+ class="gl-font-monospace!"
+ />
+ <template #append>
+ <clipboard-button
+ :text="dockerLoginCommand"
+ :title="$options.i18n.copyLoginTitle"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ <p class="gl-mb-4">
+ {{ $options.i18n.addImageText }}
+ </p>
+ <gl-form-input-group class="gl-mb-4">
+ <gl-form-input
+ :value="dockerBuildCommand"
+ readonly
+ type="text"
+ class="gl-font-monospace!"
+ />
+ <template #append>
+ <clipboard-button
+ :text="dockerBuildCommand"
+ :title="$options.i18n.copyBuildTitle"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ <gl-form-input-group>
+ <gl-form-input :value="dockerPushCommand" readonly type="text" class="gl-font-monospace!" />
+ <template #append>
+ <clipboard-button
+ :text="dockerPushCommand"
+ :title="$options.i18n.copyPushTitle"
+ class="gl-m-0!"
+ />
+ </template>
+ </gl-form-input-group>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
new file mode 100644
index 00000000000..6d2ff9ea7b6
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue
@@ -0,0 +1,110 @@
+<script>
+import { approximateDuration, calculateRemainingMilliseconds } from '~/lib/utils/datetime_utility';
+import { n__, sprintf } from '~/locale';
+import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+import {
+ CONTAINER_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+ EXPIRATION_POLICY_WILL_RUN_IN,
+ EXPIRATION_POLICY_DISABLED_TEXT,
+} from '../../constants/index';
+
+export default {
+ name: 'ListHeader',
+ components: {
+ TitleArea,
+ MetadataItem,
+ },
+ props: {
+ expirationPolicy: {
+ type: Object,
+ default: () => ({}),
+ required: false,
+ },
+ imagesCount: {
+ type: Number,
+ default: 0,
+ required: false,
+ },
+ helpPagePath: {
+ type: String,
+ default: '',
+ required: false,
+ },
+ hideExpirationPolicyData: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ metadataLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+ i18n: {
+ CONTAINER_REGISTRY_TITLE,
+ },
+ computed: {
+ imagesCountText() {
+ const pluralisedString = n__(
+ 'ContainerRegistry|%{count} Image repository',
+ 'ContainerRegistry|%{count} Image repositories',
+ this.imagesCount,
+ );
+ return sprintf(pluralisedString, { count: this.imagesCount });
+ },
+ timeTillRun() {
+ const difference = calculateRemainingMilliseconds(this.expirationPolicy?.next_run_at);
+ return approximateDuration(difference / 1000);
+ },
+ expirationPolicyEnabled() {
+ return this.expirationPolicy?.enabled;
+ },
+ expirationPolicyText() {
+ return this.expirationPolicyEnabled
+ ? sprintf(EXPIRATION_POLICY_WILL_RUN_IN, { time: this.timeTillRun })
+ : EXPIRATION_POLICY_DISABLED_TEXT;
+ },
+ infoMessages() {
+ return [{ text: LIST_INTRO_TEXT, link: this.helpPagePath }];
+ },
+ },
+};
+</script>
+
+<template>
+ <title-area
+ :title="$options.i18n.CONTAINER_REGISTRY_TITLE"
+ :info-messages="infoMessages"
+ :metadata-loading="metadataLoading"
+ >
+ <template #right-actions>
+ <slot name="commands"></slot>
+ </template>
+ <template #metadata-count>
+ <metadata-item
+ v-if="imagesCount"
+ data-testid="images-count"
+ icon="container-image"
+ :text="imagesCountText"
+ />
+ </template>
+ <template #metadata-exp-policies>
+ <metadata-item
+ v-if="!hideExpirationPolicyData"
+ data-testid="expiration-policy"
+ icon="expire"
+ :text="expirationPolicyText"
+ size="xl"
+ />
+ </template>
+ </title-area>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue
new file mode 100644
index 00000000000..e77eda31596
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue
@@ -0,0 +1,51 @@
+<script>
+// We are using gl-breadcrumb only at the last child of the handwritten breadcrumb
+// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
+//
+// See the CSS workaround in app/assets/stylesheets/pages/registry.scss when this file is changed.
+import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
+
+export default {
+ components: {
+ GlBreadcrumb,
+ GlIcon,
+ },
+ computed: {
+ rootRoute() {
+ return this.$router.options.routes.find((r) => r.meta.root);
+ },
+ detailsRoute() {
+ return this.$router.options.routes.find((r) => r.name === 'details');
+ },
+ isRootRoute() {
+ return this.$route.name === this.rootRoute.name;
+ },
+ isLoaded() {
+ return this.isRootRoute || this.$store?.state.imageDetails?.name;
+ },
+ allCrumbs() {
+ const crumbs = [
+ {
+ text: this.rootRoute.meta.nameGenerator(),
+ to: this.rootRoute.path,
+ },
+ ];
+ if (!this.isRootRoute) {
+ crumbs.push({
+ text: this.detailsRoute.meta.nameGenerator(),
+ href: this.detailsRoute.meta.path,
+ });
+ }
+ return crumbs;
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-breadcrumb :key="isLoaded" :items="allCrumbs">
+ <template #separator>
+ <gl-icon name="angle-right" :size="8" />
+ </template>
+ </gl-breadcrumb>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
new file mode 100644
index 00000000000..f7beec2c935
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/common.js
@@ -0,0 +1,4 @@
+import { s__, __ } from '~/locale';
+
+export const ROOT_IMAGE_TEXT = s__('ContainerRegistry|Root image');
+export const MORE_ACTIONS_TEXT = __('More actions');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
new file mode 100644
index 00000000000..19e1a75fb2f
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/details.js
@@ -0,0 +1,166 @@
+import { helpPagePath } from '~/helpers/help_page_helper';
+import { s__, __ } from '~/locale';
+
+// Translations strings
+export const DELETE_TAG_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while marking the tag for deletion.',
+);
+export const DELETE_TAG_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tag successfully marked for deletion.',
+);
+export const DELETE_TAGS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while marking the tags for deletion.',
+);
+export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|Tags successfully marked for deletion.',
+);
+
+export const FETCH_IMAGE_DETAILS_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the image details.',
+);
+
+export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags');
+export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}');
+export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}');
+export const PUBLISHED_DETAILS_ROW_TEXT = s__(
+ 'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
+);
+export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}');
+export const CONFIGURATION_DETAILS_ROW_TEST = s__(
+ 'ContainerRegistry|Configuration digest: %{digest}',
+);
+
+export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Delete tag');
+export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected tags');
+
+export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item}. Are you sure?`,
+);
+export const REMOVE_TAGS_CONFIRMATION_TEXT = s__(
+ `ContainerRegistry|You are about to remove %{item} tags. Are you sure?`,
+);
+export const NO_TAGS_TITLE = s__('ContainerRegistry|This image has no active tags');
+export const NO_TAGS_MESSAGE = s__(
+ `ContainerRegistry|The last tag related to this image was recently removed.
+This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
+If you have any questions, contact your administrator.`,
+);
+
+export const MISSING_OR_DELETED_IMAGE_TITLE = s__(
+ 'ContainerRegistry|The image repository could not be found.',
+);
+export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__(
+ 'ContainerRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.',
+);
+
+export const MISSING_OR_DELETED_IMAGE_BREADCRUMB = s__(
+ 'ContainerRegistry|Image repository not found',
+);
+
+export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
+ 'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
+);
+
+export const MISSING_MANIFEST_WARNING_TOOLTIP = s__(
+ 'ContainerRegistry|Invalid tag: missing manifest digest',
+);
+
+export const UPDATED_AT = s__('ContainerRegistry|Last updated %{time}');
+
+export const NOT_AVAILABLE_TEXT = __('N/A');
+export const NOT_AVAILABLE_SIZE = __('0 bytes');
+
+export const CLEANUP_UNSCHEDULED_TEXT = s__('ContainerRegistry|Cleanup will run %{time}');
+export const CLEANUP_SCHEDULED_TEXT = s__('ContainerRegistry|Cleanup pending');
+export const CLEANUP_ONGOING_TEXT = s__('ContainerRegistry|Cleanup in progress');
+export const CLEANUP_UNFINISHED_TEXT = s__('ContainerRegistry|Cleanup incomplete');
+export const CLEANUP_DISABLED_TEXT = s__('ContainerRegistry|Cleanup disabled');
+
+export const CLEANUP_SCHEDULED_TOOLTIP = s__('ContainerRegistry|Cleanup will run soon');
+export const CLEANUP_ONGOING_TOOLTIP = s__('ContainerRegistry|Cleanup is currently removing tags');
+export const CLEANUP_UNFINISHED_TOOLTIP = s__(
+ 'ContainerRegistry|Cleanup ran but some tags were not removed',
+);
+export const CLEANUP_DISABLED_TOOLTIP = s__(
+ 'ContainerRegistry|Cleanup is disabled for this project',
+);
+
+export const CLEANUP_STATUS_SCHEDULED = s__('ContainerRegistry|Cleanup will run soon');
+export const CLEANUP_STATUS_ONGOING = s__('ContainerRegistry|Cleanup is ongoing');
+export const CLEANUP_STATUS_UNFINISHED = s__('ContainerRegistry|Cleanup timed out');
+
+export const DETAILS_DELETE_IMAGE_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while scheduling the image for deletion.',
+);
+
+export const DELETE_IMAGE_CONFIRMATION_TITLE = s__('ContainerRegistry|Delete image repository?');
+export const DELETE_IMAGE_CONFIRMATION_TEXT = s__(
+ 'ContainerRegistry|Deleting the image repository will delete all images and tags inside. This action cannot be undone. Please type the following to confirm: %{code}',
+);
+
+export const SCHEDULED_FOR_DELETION_STATUS_TITLE = s__(
+ 'ContainerRegistry|Image repository will be deleted',
+);
+export const SCHEDULED_FOR_DELETION_STATUS_MESSAGE = s__(
+ 'ContainerRegistry|This image repository will be deleted. %{linkStart}Learn more.%{linkEnd}',
+);
+
+export const FAILED_DELETION_STATUS_TITLE = s__(
+ 'ContainerRegistry|Image repository deletion failed',
+);
+export const FAILED_DELETION_STATUS_MESSAGE = s__(
+ 'ContainerRegistry|This image repository has failed to be deleted',
+);
+
+export const ROOT_IMAGE_TOOLTIP = s__(
+ 'ContainerRegistry|Image repository with no name located at the project URL.',
+);
+
+// Parameters
+
+export const DEFAULT_PAGE = 1;
+export const DEFAULT_PAGE_SIZE = 10;
+export const GROUP_PAGE_TYPE = 'groups';
+export const ALERT_SUCCESS_TAG = 'success_tag';
+export const ALERT_DANGER_TAG = 'danger_tag';
+export const ALERT_SUCCESS_TAGS = 'success_tags';
+export const ALERT_DANGER_TAGS = 'danger_tags';
+export const ALERT_DANGER_IMAGE = 'danger_image';
+
+export const DELETE_SCHEDULED = 'DELETE_SCHEDULED';
+export const DELETE_FAILED = 'DELETE_FAILED';
+
+export const ALERT_MESSAGES = {
+ [ALERT_SUCCESS_TAG]: DELETE_TAG_SUCCESS_MESSAGE,
+ [ALERT_DANGER_TAG]: DELETE_TAG_ERROR_MESSAGE,
+ [ALERT_SUCCESS_TAGS]: DELETE_TAGS_SUCCESS_MESSAGE,
+ [ALERT_DANGER_TAGS]: DELETE_TAGS_ERROR_MESSAGE,
+ [ALERT_DANGER_IMAGE]: DETAILS_DELETE_IMAGE_ERROR_MESSAGE,
+};
+
+export const UNFINISHED_STATUS = 'UNFINISHED';
+export const UNSCHEDULED_STATUS = 'UNSCHEDULED';
+export const SCHEDULED_STATUS = 'SCHEDULED';
+export const ONGOING_STATUS = 'ONGOING';
+
+export const IMAGE_STATUS_TITLES = {
+ [DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_TITLE,
+ [DELETE_FAILED]: FAILED_DELETION_STATUS_TITLE,
+};
+
+export const IMAGE_STATUS_MESSAGES = {
+ [DELETE_SCHEDULED]: SCHEDULED_FOR_DELETION_STATUS_MESSAGE,
+ [DELETE_FAILED]: FAILED_DELETION_STATUS_MESSAGE,
+};
+
+export const IMAGE_STATUS_ALERT_TYPE = {
+ [DELETE_SCHEDULED]: 'info',
+ [DELETE_FAILED]: 'warning',
+};
+
+export const PACKAGE_DELETE_HELP_PAGE_PATH = helpPagePath(
+ 'user/packages/container_registry/index',
+ {
+ anchor: 'delete-images',
+ },
+);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
new file mode 100644
index 00000000000..40f9b09a982
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/expiration_policies.js
@@ -0,0 +1,15 @@
+import { s__ } from '~/locale';
+
+export const EXPIRATION_POLICY_WILL_RUN_IN = s__(
+ 'ContainerRegistry|Expiration policy will run in %{time}',
+);
+export const EXPIRATION_POLICY_DISABLED_TEXT = s__(
+ 'ContainerRegistry|Expiration policy is disabled',
+);
+export const DELETE_ALERT_TITLE = s__('ContainerRegistry|Some tags were not deleted');
+export const DELETE_ALERT_LINK_TEXT = s__(
+ 'ContainerRegistry|The cleanup policy timed out before it could delete all tags. An administrator can %{adminLinkStart}manually run cleanup now%{adminLinkEnd} or you can wait for the cleanup policy to automatically run again. %{docLinkStart}More information%{docLinkEnd}',
+);
+export const CLEANUP_TIMED_OUT_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Cleanup timed out before it could delete all tags',
+);
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js
new file mode 100644
index 00000000000..6886356d8e2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/index.js
@@ -0,0 +1,5 @@
+export * from './common';
+export * from './expiration_policies';
+export * from './quick_start';
+export * from './list';
+export * from './details';
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
new file mode 100644
index 00000000000..d21a154d1b8
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/list.js
@@ -0,0 +1,53 @@
+import { s__, __ } from '~/locale';
+
+// Translations strings
+
+export const CONTAINER_REGISTRY_TITLE = s__('ContainerRegistry|Container Registry');
+export const CONNECTION_ERROR_TITLE = s__('ContainerRegistry|Docker connection error');
+export const CONNECTION_ERROR_MESSAGE = s__(
+ `ContainerRegistry|We are having trouble connecting to the Container Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`,
+);
+export const LIST_INTRO_TEXT = s__(
+ `ContainerRegistry|With the GitLab Container Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`,
+);
+export const LIST_DELETE_BUTTON_DISABLED = s__(
+ 'ContainerRegistry|Missing or insufficient permission, delete button disabled',
+);
+export const REMOVE_REPOSITORY_LABEL = s__('ContainerRegistry|Remove repository');
+export const REMOVE_REPOSITORY_MODAL_TEXT = s__(
+ 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
+);
+export const ROW_SCHEDULED_FOR_DELETION = s__(
+ `ContainerRegistry|This image repository is scheduled for deletion`,
+);
+export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the repository list.',
+);
+export const FETCH_TAGS_LIST_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while fetching the tags list.',
+);
+export const DELETE_IMAGE_ERROR_MESSAGE = s__(
+ 'ContainerRegistry|Something went wrong while scheduling %{title} for deletion. Please try again.',
+);
+export const ASYNC_DELETE_IMAGE_ERROR_MESSAGE = s__(
+ `ContainerRegistry|There was an error during the deletion of this image repository, please try again.`,
+);
+export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
+ 'ContainerRegistry|%{title} was successfully scheduled for deletion',
+);
+export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
+export const EMPTY_RESULT_MESSAGE = s__(
+ 'ContainerRegistry|To widen your search, change or remove the filters above.',
+);
+
+// Parameters
+
+export const IMAGE_DELETE_SCHEDULED_STATUS = 'DELETE_SCHEDULED';
+export const IMAGE_FAILED_DELETED_STATUS = 'DELETE_FAILED';
+export const GRAPHQL_PAGE_SIZE = 10;
+
+export const SORT_FIELDS = [
+ { orderBy: 'UPDATED', label: __('Updated') },
+ { orderBy: 'CREATED', label: __('Created') },
+ { orderBy: 'NAME', label: __('Name') },
+];
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js
new file mode 100644
index 00000000000..6a39c07eba2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/constants/quick_start.js
@@ -0,0 +1,9 @@
+import { s__ } from '~/locale';
+
+export const QUICK_START = s__('ContainerRegistry|CLI Commands');
+export const LOGIN_COMMAND_LABEL = s__('ContainerRegistry|Login');
+export const COPY_LOGIN_TITLE = s__('ContainerRegistry|Copy login command');
+export const BUILD_COMMAND_LABEL = s__('ContainerRegistry|Build an image');
+export const COPY_BUILD_TITLE = s__('ContainerRegistry|Copy build command');
+export const PUSH_COMMAND_LABEL = s__('ContainerRegistry|Push an image');
+export const COPY_PUSH_TITLE = s__('ContainerRegistry|Copy push command');
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
new file mode 100644
index 00000000000..9694bfd4e77
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/index.js
@@ -0,0 +1,14 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+
+Vue.use(VueApollo);
+
+export const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(
+ {},
+ {
+ batchMax: 1,
+ },
+ ),
+});
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
new file mode 100644
index 00000000000..4c88b726ee5
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql
@@ -0,0 +1,9 @@
+mutation destroyContainerRepository($id: ContainerRepositoryID!) {
+ destroyContainerRepository(input: { id: $id }) {
+ containerRepository {
+ id
+ status
+ }
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
new file mode 100644
index 00000000000..a31f2829e13
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql
@@ -0,0 +1,5 @@
+mutation destroyContainerRepositoryTags($id: ContainerRepositoryID!, $tagNames: [String!]!) {
+ destroyContainerRepositoryTags(input: { id: $id, tagNames: $tagNames }) {
+ errors
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
new file mode 100644
index 00000000000..01cb7fa1cab
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql
@@ -0,0 +1,41 @@
+query getContainerRepositoriesDetails(
+ $fullPath: ID!
+ $name: String
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+ $isGroupPage: Boolean!
+ $sort: ContainerRepositorySort
+) {
+ project(fullPath: $fullPath) @skip(if: $isGroupPage) {
+ containerRepositories(
+ name: $name
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ sort: $sort
+ ) {
+ nodes {
+ id
+ tagsCount
+ }
+ }
+ }
+ group(fullPath: $fullPath) @include(if: $isGroupPage) {
+ containerRepositories(
+ name: $name
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ sort: $sort
+ ) {
+ nodes {
+ id
+ tagsCount
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
new file mode 100644
index 00000000000..b5a99fd9ac1
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql
@@ -0,0 +1,22 @@
+query getContainerRepositoryDetails($id: ID!) {
+ containerRepository(id: $id) {
+ id
+ name
+ path
+ status
+ location
+ canDelete
+ createdAt
+ updatedAt
+ expirationPolicyStartedAt
+ expirationPolicyCleanupStatus
+ project {
+ visibility
+ path
+ containerExpirationPolicy {
+ enabled
+ nextRunAt
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
new file mode 100644
index 00000000000..a703c2dd0ac
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql
@@ -0,0 +1,29 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getContainerRepositoryTags(
+ $id: ID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
+ containerRepository(id: $id) {
+ id
+ tags(after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ digest
+ location
+ path
+ name
+ revision
+ shortRevision
+ createdAt
+ totalSize
+ canDelete
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
new file mode 100644
index 00000000000..9092a71edb0
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql
@@ -0,0 +1,6 @@
+query getContainerRepositoryTagsCount($id: ID!) {
+ containerRepository(id: $id) {
+ id
+ tagsCount
+ }
+}
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
new file mode 100644
index 00000000000..246a6768593
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/index.js
@@ -0,0 +1,121 @@
+import { GlToast } from '@gitlab/ui';
+import Vue from 'vue';
+import { parseBoolean } from '~/lib/utils/common_utils';
+import PerformancePlugin from '~/performance/vue_performance_plugin';
+import Translate from '~/vue_shared/translate';
+import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
+import { apolloProvider } from './graphql/index';
+import RegistryExplorer from './pages/index.vue';
+import createRouter from './router';
+
+Vue.use(Translate);
+Vue.use(GlToast);
+
+Vue.use(PerformancePlugin, {
+ components: [
+ 'RegistryListPage',
+ 'ListHeader',
+ 'ImageListRow',
+ 'RegistryDetailsPage',
+ 'DetailsHeader',
+ 'TagsList',
+ ],
+});
+
+export default () => {
+ const el = document.getElementById('js-container-registry');
+
+ if (!el) {
+ return null;
+ }
+
+ const {
+ endpoint,
+ expirationPolicy,
+ isGroupPage,
+ isAdmin,
+ showCleanupPolicyOnAlert,
+ showUnfinishedTagCleanupCallout,
+ connectionError,
+ invalidPathError,
+ ...config
+ } = el.dataset;
+
+ // This is a mini state to help the breadcrumb have the correct name in the details page
+ const breadCrumbState = Vue.observable({
+ name: '',
+ updateName(value) {
+ this.name = value;
+ },
+ });
+
+ const router = createRouter(endpoint, breadCrumbState);
+
+ const attachMainComponent = () =>
+ new Vue({
+ el,
+ router,
+ apolloProvider,
+ components: {
+ RegistryExplorer,
+ },
+ provide() {
+ return {
+ breadCrumbState,
+ config: {
+ ...config,
+ expirationPolicy: expirationPolicy ? JSON.parse(expirationPolicy) : undefined,
+ isGroupPage: parseBoolean(isGroupPage),
+ isAdmin: parseBoolean(isAdmin),
+ showCleanupPolicyOnAlert: parseBoolean(showCleanupPolicyOnAlert),
+ showUnfinishedTagCleanupCallout: parseBoolean(showUnfinishedTagCleanupCallout),
+ connectionError: parseBoolean(connectionError),
+ invalidPathError: parseBoolean(invalidPathError),
+ },
+ /* eslint-disable @gitlab/require-i18n-strings */
+ dockerBuildCommand: `docker build -t ${config.repositoryUrl} .`,
+ dockerPushCommand: `docker push ${config.repositoryUrl}`,
+ dockerLoginCommand: `docker login ${config.registryHostUrlWithPort}`,
+ /* eslint-enable @gitlab/require-i18n-strings */
+ };
+ },
+ render(createElement) {
+ return createElement('registry-explorer');
+ },
+ });
+
+ const attachBreadcrumb = () => {
+ const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li');
+ const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1];
+ const crumbs = [breadCrumbEl.querySelector('h2')];
+ const nestedBreadcrumbEl = document.createElement('div');
+ breadCrumbEl.replaceChild(nestedBreadcrumbEl, breadCrumbEl.querySelector('h2'));
+ return new Vue({
+ el: nestedBreadcrumbEl,
+ router,
+ apolloProvider,
+ components: {
+ RegistryBreadcrumb,
+ },
+ render(createElement) {
+ // FIXME(@tnir): this is a workaround until the MR gets merged:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
+ const parentEl = breadCrumbEl.parentElement.parentElement;
+ if (parentEl) {
+ parentEl.classList.remove('breadcrumbs-container');
+ parentEl.classList.add('gl-display-flex');
+ parentEl.classList.add('w-100');
+ }
+ // End of FIXME(@tnir)
+ return createElement('registry-breadcrumb', {
+ class: breadCrumbEl.className,
+ props: {
+ crumbs,
+ },
+ });
+ },
+ });
+ };
+
+ return { attachBreadcrumb, attachMainComponent };
+};
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
new file mode 100644
index 00000000000..feabc4f770b
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/details.vue
@@ -0,0 +1,233 @@
+<script>
+import { GlResizeObserverDirective } from '@gitlab/ui';
+import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
+import createFlash from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { joinPaths } from '~/lib/utils/url_utility';
+import Tracking from '~/tracking';
+import DeleteImage from '../components/delete_image.vue';
+import DeleteAlert from '../components/details_page/delete_alert.vue';
+import DeleteModal from '../components/details_page/delete_modal.vue';
+import DetailsHeader from '../components/details_page/details_header.vue';
+import EmptyState from '../components/details_page/empty_state.vue';
+import PartialCleanupAlert from '../components/details_page/partial_cleanup_alert.vue';
+import StatusAlert from '../components/details_page/status_alert.vue';
+import TagsList from '../components/details_page/tags_list.vue';
+import TagsLoader from '../components/details_page/tags_loader.vue';
+
+import {
+ ALERT_SUCCESS_TAG,
+ ALERT_DANGER_TAG,
+ ALERT_SUCCESS_TAGS,
+ ALERT_DANGER_TAGS,
+ ALERT_DANGER_IMAGE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ UNFINISHED_STATUS,
+ MISSING_OR_DELETED_IMAGE_BREADCRUMB,
+ ROOT_IMAGE_TEXT,
+} from '../constants/index';
+import deleteContainerRepositoryTagsMutation from '../graphql/mutations/delete_container_repository_tags.mutation.graphql';
+import getContainerRepositoryDetailsQuery from '../graphql/queries/get_container_repository_details.query.graphql';
+
+export default {
+ name: 'RegistryDetailsPage',
+ components: {
+ DeleteAlert,
+ PartialCleanupAlert,
+ DetailsHeader,
+ DeleteModal,
+ TagsList,
+ TagsLoader,
+ EmptyState,
+ StatusAlert,
+ DeleteImage,
+ },
+ directives: {
+ GlResizeObserver: GlResizeObserverDirective,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['breadCrumbState', 'config'],
+ apollo: {
+ containerRepository: {
+ query: getContainerRepositoryDetailsQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ result() {
+ this.updateBreadcrumb();
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
+ data() {
+ return {
+ containerRepository: {},
+ itemsToBeDeleted: [],
+ isMobile: false,
+ mutationLoading: false,
+ deleteAlertType: null,
+ hidePartialCleanupWarning: false,
+ deleteImageAlert: false,
+ };
+ },
+ computed: {
+ queryVariables() {
+ return {
+ id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
+ };
+ },
+ isLoading() {
+ return this.$apollo.queries.containerRepository.loading || this.mutationLoading;
+ },
+ showPartialCleanupWarning() {
+ return (
+ this.config.showUnfinishedTagCleanupCallout &&
+ this.containerRepository?.expirationPolicyCleanupStatus === UNFINISHED_STATUS &&
+ !this.hidePartialCleanupWarning
+ );
+ },
+ tracking() {
+ return {
+ label:
+ this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
+ };
+ },
+ pageActionsAreDisabled() {
+ return Boolean(this.containerRepository?.status);
+ },
+ },
+ methods: {
+ updateBreadcrumb() {
+ const name = this.containerRepository?.id
+ ? this.containerRepository?.name || ROOT_IMAGE_TEXT
+ : MISSING_OR_DELETED_IMAGE_BREADCRUMB;
+ this.breadCrumbState.updateName(name);
+ },
+ deleteTags(toBeDeleted) {
+ this.deleteImageAlert = false;
+ this.itemsToBeDeleted = toBeDeleted;
+ this.track('click_button');
+ this.$refs.deleteModal.show();
+ },
+ confirmDelete() {
+ if (this.deleteImageAlert) {
+ this.$refs.deleteImage.doDelete();
+ } else {
+ this.handleDeleteTag();
+ }
+ },
+ async handleDeleteTag() {
+ this.track('confirm_delete');
+ const { itemsToBeDeleted } = this;
+ this.itemsToBeDeleted = [];
+ this.mutationLoading = true;
+ try {
+ const { data } = await this.$apollo.mutate({
+ mutation: deleteContainerRepositoryTagsMutation,
+ variables: {
+ id: this.queryVariables.id,
+ tagNames: itemsToBeDeleted.map((i) => i.name),
+ },
+ awaitRefetchQueries: true,
+ refetchQueries: [
+ {
+ query: getContainerRepositoryDetailsQuery,
+ variables: this.queryVariables,
+ },
+ ],
+ });
+
+ if (data?.destroyContainerRepositoryTags?.errors[0]) {
+ throw new Error();
+ }
+ this.deleteAlertType =
+ itemsToBeDeleted.length === 0 ? ALERT_SUCCESS_TAG : ALERT_SUCCESS_TAGS;
+ } catch (e) {
+ this.deleteAlertType = itemsToBeDeleted.length === 0 ? ALERT_DANGER_TAG : ALERT_DANGER_TAGS;
+ }
+
+ this.mutationLoading = false;
+ },
+ handleResize() {
+ this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
+ },
+ dismissPartialCleanupWarning() {
+ this.hidePartialCleanupWarning = true;
+ axios.post(this.config.userCalloutsPath, {
+ feature_name: this.config.userCalloutId,
+ });
+ },
+ deleteImage() {
+ this.deleteImageAlert = true;
+ this.itemsToBeDeleted = [{ ...this.containerRepository }];
+ this.$refs.deleteModal.show();
+ },
+ deleteImageError() {
+ this.deleteAlertType = ALERT_DANGER_IMAGE;
+ },
+ deleteImageIniit() {
+ this.itemsToBeDeleted = [];
+ this.mutationLoading = true;
+ },
+ },
+};
+</script>
+
+<template>
+ <div v-gl-resize-observer="handleResize" class="gl-my-3">
+ <template v-if="containerRepository">
+ <delete-alert
+ v-model="deleteAlertType"
+ :garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
+ :is-admin="config.isAdmin"
+ class="gl-my-2"
+ />
+
+ <partial-cleanup-alert
+ v-if="showPartialCleanupWarning"
+ :run-cleanup-policies-help-page-path="config.runCleanupPoliciesHelpPagePath"
+ :cleanup-policies-help-page-path="config.expirationPolicyHelpPagePath"
+ @dismiss="dismissPartialCleanupWarning"
+ />
+
+ <status-alert v-if="containerRepository.status" :status="containerRepository.status" />
+
+ <details-header
+ v-if="!isLoading"
+ :image="containerRepository"
+ :disabled="pageActionsAreDisabled"
+ @delete="deleteImage"
+ />
+
+ <tags-loader v-if="isLoading" />
+ <tags-list
+ v-else
+ :id="$route.params.id"
+ :is-image-loading="isLoading"
+ :is-mobile="isMobile"
+ :disabled="pageActionsAreDisabled"
+ @delete="deleteTags"
+ />
+
+ <delete-image
+ :id="containerRepository.id"
+ ref="deleteImage"
+ use-update-fn
+ @start="deleteImageIniit"
+ @error="deleteImageError"
+ @end="mutationLoading = false"
+ />
+
+ <delete-modal
+ ref="deleteModal"
+ :items-to-be-deleted="itemsToBeDeleted"
+ :delete-image="deleteImageAlert"
+ @confirmDelete="confirmDelete"
+ @cancel="track('cancel_delete')"
+ />
+ </template>
+ <empty-state v-else is-empty-image :no-containers-image="config.noContainersImage" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
new file mode 100644
index 00000000000..dca63e1a569
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/index.vue
@@ -0,0 +1,5 @@
+<template>
+ <div>
+ <router-view ref="router-view" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
new file mode 100644
index 00000000000..73b957f42f2
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue
@@ -0,0 +1,409 @@
+<script>
+import {
+ GlEmptyState,
+ GlTooltipDirective,
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlAlert,
+ GlSkeletonLoader,
+} from '@gitlab/ui';
+import { get } from 'lodash';
+import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
+import createFlash from '~/flash';
+import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import { extractFilterAndSorting } from '~/packages_and_registries/shared/utils';
+import Tracking from '~/tracking';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import DeleteImage from '../components/delete_image.vue';
+import RegistryHeader from '../components/list_page/registry_header.vue';
+
+import {
+ DELETE_IMAGE_SUCCESS_MESSAGE,
+ DELETE_IMAGE_ERROR_MESSAGE,
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ REMOVE_REPOSITORY_MODAL_TEXT,
+ REMOVE_REPOSITORY_LABEL,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
+ GRAPHQL_PAGE_SIZE,
+ FETCH_IMAGES_LIST_ERROR_MESSAGE,
+ SORT_FIELDS,
+} from '../constants/index';
+import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
+
+export default {
+ name: 'RegistryListPage',
+ components: {
+ GlEmptyState,
+ ProjectEmptyState: () =>
+ import(
+ /* webpackChunkName: 'container_registry_components' */ '../components/list_page/project_empty_state.vue'
+ ),
+ GroupEmptyState: () =>
+ import(
+ /* webpackChunkName: 'container_registry_components' */ '../components/list_page/group_empty_state.vue'
+ ),
+ ImageList: () =>
+ import(
+ /* webpackChunkName: 'container_registry_components' */ '../components/list_page/image_list.vue'
+ ),
+ CliCommands: () =>
+ import(
+ /* webpackChunkName: 'container_registry_components' */ '../components/list_page/cli_commands.vue'
+ ),
+ GlModal,
+ GlSprintf,
+ GlLink,
+ GlAlert,
+ GlSkeletonLoader,
+ RegistryHeader,
+ DeleteImage,
+ RegistrySearch,
+ CleanupPolicyEnabledAlert,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ mixins: [Tracking.mixin()],
+ inject: ['config'],
+ loader: {
+ repeat: 10,
+ width: 1000,
+ height: 40,
+ },
+ i18n: {
+ CONNECTION_ERROR_TITLE,
+ CONNECTION_ERROR_MESSAGE,
+ REMOVE_REPOSITORY_MODAL_TEXT,
+ REMOVE_REPOSITORY_LABEL,
+ EMPTY_RESULT_TITLE,
+ EMPTY_RESULT_MESSAGE,
+ },
+ searchConfig: SORT_FIELDS,
+ apollo: {
+ baseImages: {
+ skip() {
+ return !this.fetchBaseQuery;
+ },
+ query: getContainerRepositoriesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource]?.containerRepositories.nodes;
+ },
+ result({ data }) {
+ this.pageInfo = data[this.graphqlResource]?.containerRepositories?.pageInfo;
+ this.containerRepositoriesCount = data[this.graphqlResource]?.containerRepositoriesCount;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ additionalDetails: {
+ skip() {
+ return !this.fetchAdditionalDetails;
+ },
+ query: getContainerRepositoriesDetails,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return data[this.graphqlResource]?.containerRepositories.nodes;
+ },
+ error() {
+ createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
+ },
+ },
+ },
+ data() {
+ return {
+ baseImages: [],
+ additionalDetails: [],
+ pageInfo: {},
+ containerRepositoriesCount: 0,
+ itemToDelete: {},
+ deleteAlertType: null,
+ filter: [],
+ sorting: { orderBy: 'UPDATED', sort: 'desc' },
+ name: null,
+ mutationLoading: false,
+ fetchBaseQuery: false,
+ fetchAdditionalDetails: false,
+ };
+ },
+ computed: {
+ images() {
+ if (this.baseImages) {
+ return this.baseImages.map((image, index) => ({
+ ...image,
+ ...get(this.additionalDetails, index, {}),
+ }));
+ }
+ return [];
+ },
+ graphqlResource() {
+ return this.config.isGroupPage ? 'group' : 'project';
+ },
+ queryVariables() {
+ return {
+ name: this.name,
+ sort: this.sortBy,
+ fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
+ isGroupPage: this.config.isGroupPage,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+ },
+ tracking() {
+ return {
+ label: 'registry_repository_delete',
+ };
+ },
+ isLoading() {
+ return this.$apollo.queries.baseImages.loading || this.mutationLoading;
+ },
+ showCommands() {
+ return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
+ },
+ showDeleteAlert() {
+ return this.deleteAlertType && this.itemToDelete?.path;
+ },
+ showConnectionError() {
+ return this.config.connectionError || this.config.invalidPathError;
+ },
+ deleteImageAlertMessage() {
+ return this.deleteAlertType === 'success'
+ ? DELETE_IMAGE_SUCCESS_MESSAGE
+ : DELETE_IMAGE_ERROR_MESSAGE;
+ },
+ sortBy() {
+ const { orderBy, sort } = this.sorting;
+ return `${orderBy}_${sort}`.toUpperCase();
+ },
+ },
+ mounted() {
+ const { sorting, filters } = extractFilterAndSorting(this.$route.query);
+
+ this.filter = [...filters];
+ this.name = filters[0]?.value.data;
+ this.sorting = { ...this.sorting, ...sorting };
+
+ // If the two graphql calls - which are not batched - resolve togheter we will have a race
+ // condition when apollo sets the cache, with this we give the 'base' call an headstart
+ this.fetchBaseQuery = true;
+ setTimeout(() => {
+ this.fetchAdditionalDetails = true;
+ }, 200);
+ },
+ methods: {
+ deleteImage(item) {
+ this.track('click_button');
+ this.itemToDelete = item;
+ this.$refs.deleteModal.show();
+ },
+ dismissDeleteAlert() {
+ this.deleteAlertType = null;
+ this.itemToDelete = {};
+ },
+ updateQuery(_, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ async fetchNextPage() {
+ if (this.pageInfo?.hasNextPage) {
+ const variables = {
+ after: this.pageInfo?.endCursor,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+
+ this.$apollo.queries.baseImages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+
+ await this.$nextTick();
+
+ this.$apollo.queries.additionalDetails.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ }
+ },
+ async fetchPreviousPage() {
+ if (this.pageInfo?.hasPreviousPage) {
+ const variables = {
+ first: null,
+ before: this.pageInfo?.startCursor,
+ last: GRAPHQL_PAGE_SIZE,
+ };
+ this.$apollo.queries.baseImages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+
+ await this.$nextTick();
+
+ this.$apollo.queries.additionalDetails.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ }
+ },
+ startDelete() {
+ this.track('confirm_delete');
+ this.mutationLoading = true;
+ },
+ updateSorting(value) {
+ this.sorting = {
+ ...this.sorting,
+ ...value,
+ };
+ },
+ doFilter() {
+ const search = this.filter.find((i) => i.type === FILTERED_SEARCH_TERM);
+ this.name = search?.value?.data;
+ },
+ updateUrlQueryString(query) {
+ this.$router.push({ query });
+ },
+ },
+};
+</script>
+
+<template>
+ <div>
+ <gl-alert
+ v-if="showDeleteAlert"
+ :variant="deleteAlertType"
+ class="gl-mt-5"
+ dismissible
+ @dismiss="dismissDeleteAlert"
+ >
+ <gl-sprintf :message="deleteImageAlertMessage">
+ <template #title>
+ {{ itemToDelete.path }}
+ </template>
+ </gl-sprintf>
+ </gl-alert>
+
+ <cleanup-policy-enabled-alert
+ v-if="config.showCleanupPolicyOnAlert"
+ :project-path="config.projectPath"
+ :cleanup-policies-settings-path="config.cleanupPoliciesSettingsPath"
+ />
+
+ <gl-empty-state
+ v-if="showConnectionError"
+ :title="$options.i18n.CONNECTION_ERROR_TITLE"
+ :svg-path="config.containersErrorImage"
+ >
+ <template #description>
+ <p>
+ <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
+ <template #docLink="{ content }">
+ <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
+ {{ content }}
+ </gl-link>
+ </template>
+ </gl-sprintf>
+ </p>
+ </template>
+ </gl-empty-state>
+
+ <template v-else>
+ <registry-header
+ :metadata-loading="isLoading"
+ :images-count="containerRepositoriesCount"
+ :expiration-policy="config.expirationPolicy"
+ :help-page-path="config.helpPagePath"
+ :hide-expiration-policy-data="config.isGroupPage"
+ >
+ <template #commands>
+ <cli-commands v-if="showCommands" />
+ </template>
+ </registry-header>
+
+ <registry-search
+ :filter="filter"
+ :sorting="sorting"
+ :tokens="[]"
+ :sortable-fields="$options.searchConfig"
+ @sorting:changed="updateSorting"
+ @filter:changed="filter = $event"
+ @filter:submit="doFilter"
+ @query:changed="updateUrlQueryString"
+ />
+
+ <div v-if="isLoading" class="gl-mt-5">
+ <gl-skeleton-loader
+ v-for="index in $options.loader.repeat"
+ :key="index"
+ :width="$options.loader.width"
+ :height="$options.loader.height"
+ preserve-aspect-ratio="xMinYMax meet"
+ >
+ <rect width="500" x="10" y="10" height="20" rx="4" />
+ <circle cx="525" cy="20" r="10" />
+ <rect x="960" y="0" width="40" height="40" rx="4" />
+ </gl-skeleton-loader>
+ </div>
+ <template v-else>
+ <template v-if="images.length > 0 || name">
+ <image-list
+ v-if="images.length"
+ :images="images"
+ :metadata-loading="$apollo.queries.additionalDetails.loading"
+ :page-info="pageInfo"
+ @delete="deleteImage"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
+
+ <gl-empty-state
+ v-else
+ :svg-path="config.noContainersImage"
+ data-testid="emptySearch"
+ :title="$options.i18n.EMPTY_RESULT_TITLE"
+ >
+ <template #description>
+ {{ $options.i18n.EMPTY_RESULT_MESSAGE }}
+ </template>
+ </gl-empty-state>
+ </template>
+ <template v-else>
+ <project-empty-state v-if="!config.isGroupPage" />
+ <group-empty-state v-else />
+ </template>
+ </template>
+
+ <delete-image
+ :id="itemToDelete.id"
+ @start="startDelete"
+ @error="deleteAlertType = 'danger'"
+ @success="deleteAlertType = 'success'"
+ @end="mutationLoading = false"
+ >
+ <template #default="{ doDelete }">
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-image-modal"
+ :action-primary="{ text: __('Remove'), attributes: { variant: 'danger' } }"
+ @primary="doDelete"
+ @cancel="track('cancel_delete')"
+ >
+ <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
+ <p>
+ <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
+ <template #title>
+ <b>{{ itemToDelete.path }}</b>
+ </template>
+ </gl-sprintf>
+ </p>
+ </gl-modal>
+ </template>
+ </delete-image>
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js
new file mode 100644
index 00000000000..a0c4417d549
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/router.js
@@ -0,0 +1,35 @@
+import Vue from 'vue';
+import VueRouter from 'vue-router';
+import { CONTAINER_REGISTRY_TITLE } from './constants/index';
+import Details from './pages/details.vue';
+import List from './pages/list.vue';
+
+Vue.use(VueRouter);
+
+export default function createRouter(base, breadCrumbState) {
+ const router = new VueRouter({
+ base,
+ mode: 'history',
+ routes: [
+ {
+ name: 'list',
+ path: '/',
+ component: List,
+ meta: {
+ nameGenerator: () => CONTAINER_REGISTRY_TITLE,
+ root: true,
+ },
+ },
+ {
+ name: 'details',
+ path: '/:id',
+ component: Details,
+ meta: {
+ nameGenerator: () => breadCrumbState.name,
+ },
+ },
+ ],
+ });
+
+ return router;
+}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
index 73fb3656af1..71e8cf4f634 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/app.vue
@@ -1,32 +1,49 @@
<script>
-import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
-import { __ } from '~/locale';
+import {
+ GlAlert,
+ GlFormGroup,
+ GlFormInputGroup,
+ GlSkeletonLoader,
+ GlSprintf,
+ GlEmptyState,
+} from '@gitlab/ui';
+import { s__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import ManifestsList from '~/packages_and_registries/dependency_proxy/components/manifests_list.vue';
import {
DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
+import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
export default {
components: {
- GlFormGroup,
GlAlert,
+ GlEmptyState,
+ GlFormGroup,
GlFormInputGroup,
+ GlSkeletonLoader,
GlSprintf,
ClipboardButton,
TitleArea,
- GlSkeletonLoader,
+ ManifestsList,
},
- inject: ['groupPath', 'dependencyProxyAvailable'],
+ inject: ['groupPath', 'dependencyProxyAvailable', 'noManifestsIllustration'],
i18n: {
- proxyNotAvailableText: __('Dependency Proxy feature is limited to public groups for now.'),
- proxyDisabledText: __('Dependency Proxy disabled. To enable it, contact the group owner.'),
- proxyImagePrefix: __('Dependency Proxy image prefix'),
- copyImagePrefixText: __('Copy prefix'),
- blobCountAndSize: __('Contains %{count} blobs of images (%{size})'),
+ proxyNotAvailableText: s__(
+ 'DependencyProxy|Dependency Proxy feature is limited to public groups for now.',
+ ),
+ proxyDisabledText: s__(
+ 'DependencyProxy|Dependency Proxy disabled. To enable it, contact the group owner.',
+ ),
+ proxyImagePrefix: s__('DependencyProxy|Dependency Proxy image prefix'),
+ copyImagePrefixText: s__('DependencyProxy|Copy prefix'),
+ blobCountAndSize: s__('DependencyProxy|Contains %{count} blobs of images (%{size})'),
+ pageTitle: s__('DependencyProxy|Dependency Proxy'),
+ noManifestTitle: s__('DependencyProxy|There are no images in the cache'),
},
data() {
return {
@@ -40,7 +57,7 @@ export default {
return !this.dependencyProxyAvailable;
},
variables() {
- return { fullPath: this.groupPath };
+ return this.queryVariables;
},
},
},
@@ -56,13 +73,45 @@ export default {
dependencyProxyEnabled() {
return this.group?.dependencyProxySetting?.enabled;
},
+ queryVariables() {
+ return { fullPath: this.groupPath, first: GRAPHQL_PAGE_SIZE };
+ },
+ pageInfo() {
+ return this.group.dependencyProxyManifests.pageInfo;
+ },
+ manifests() {
+ return this.group.dependencyProxyManifests.nodes;
+ },
+ },
+ methods: {
+ fetchNextPage() {
+ this.fetchMore({
+ first: GRAPHQL_PAGE_SIZE,
+ after: this.pageInfo?.endCursor,
+ });
+ },
+ fetchPreviousPage() {
+ this.fetchMore({
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ });
+ },
+ fetchMore(variables) {
+ this.$apollo.queries.group.fetchMore({
+ variables: { ...this.queryVariables, ...variables },
+ updateQuery(_, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ });
+ },
},
};
</script>
<template>
<div>
- <title-area :title="__('Dependency Proxy')" :info-messages="infoMessages" />
+ <title-area :title="$options.i18n.pageTitle" :info-messages="infoMessages" />
<gl-alert
v-if="!dependencyProxyAvailable"
:dismissible="false"
@@ -97,6 +146,20 @@ export default {
</span>
</template>
</gl-form-group>
+
+ <manifests-list
+ v-if="manifests && manifests.length"
+ :manifests="manifests"
+ :pagination="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ />
+
+ <gl-empty-state
+ v-else
+ :svg-path="noManifestsIllustration"
+ :title="$options.i18n.noManifestTitle"
+ />
</div>
<gl-alert v-else :dismissible="false" data-testid="proxy-disabled">
{{ $options.i18n.proxyDisabledText }}
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
new file mode 100644
index 00000000000..78880b6e3f4
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifest_row.vue
@@ -0,0 +1,49 @@
+<script>
+import { GlSprintf } from '@gitlab/ui';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+import { s__ } from '~/locale';
+
+export default {
+ name: 'ManifestRow',
+ components: {
+ GlSprintf,
+ ListItem,
+ TimeagoTooltip,
+ },
+ props: {
+ manifest: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ name() {
+ return this.manifest?.imageName.split(':')[0];
+ },
+ version() {
+ return this.manifest?.imageName.split(':')[1];
+ },
+ },
+ i18n: {
+ cachedAgoMessage: s__('DependencyProxy|Cached %{time}'),
+ },
+};
+</script>
+
+<template>
+ <list-item>
+ <template #left-primary> {{ name }} </template>
+ <template #left-secondary> {{ version }} </template>
+ <template #right-primary> &nbsp; </template>
+ <template #right-secondary>
+ <timeago-tooltip :time="manifest.createdAt" data-testid="cached-message">
+ <template #default="{ timeAgo }">
+ <gl-sprintf :message="$options.i18n.cachedAgoMessage">
+ <template #time>{{ timeAgo }}</template>
+ </gl-sprintf>
+ </template>
+ </timeago-tooltip>
+ </template>
+ </list-item>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
new file mode 100644
index 00000000000..005c8feea3a
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/components/manifests_list.vue
@@ -0,0 +1,52 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue';
+
+export default {
+ name: 'ManifestsLists',
+ components: {
+ ManifestRow,
+ GlKeysetPagination,
+ },
+ props: {
+ manifests: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ pagination: {
+ type: Object,
+ required: true,
+ },
+ },
+ i18n: {
+ listTitle: s__('DependencyProxy|Image list'),
+ },
+ computed: {
+ showPagination() {
+ return this.pagination.hasNextPage || this.pagination.hasPreviousPage;
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="gl-mt-6">
+ <h3 class="gl-font-base">{{ $options.i18n.listTitle }}</h3>
+ <div
+ class="gl-border-t-1 gl-border-gray-100 gl-border-t-solid gl-display-flex gl-flex-direction-column"
+ >
+ <manifest-row v-for="(manifest, index) in manifests" :key="index" :manifest="manifest" />
+ </div>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pagination"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
new file mode 100644
index 00000000000..3c6ede6fdce
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/constants.js
@@ -0,0 +1 @@
+export const GRAPHQL_PAGE_SIZE = 20;
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
index 9058d349bf3..63d5469c955 100644
--- a/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql
@@ -1,4 +1,12 @@
-query getDependencyProxyDetails($fullPath: ID!) {
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getDependencyProxyDetails(
+ $fullPath: ID!
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
+) {
group(fullPath: $fullPath) {
dependencyProxyBlobCount
dependencyProxyTotalSize
@@ -6,5 +14,14 @@ query getDependencyProxyDetails($fullPath: ID!) {
dependencyProxySetting {
enabled
}
+ dependencyProxyManifests(after: $after, before: $before, first: $first, last: $last) {
+ nodes {
+ createdAt
+ imageName
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
index 3d3fa62fd43..bcbeec72961 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue
@@ -23,6 +23,7 @@ import PackageFiles from '~/packages_and_registries/package_registry/components/
import PackageHistory from '~/packages_and_registries/package_registry/components/details/package_history.vue';
import PackageTitle from '~/packages_and_registries/package_registry/components/details/package_title.vue';
import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue';
+import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import {
PACKAGE_TYPE_NUGET,
PACKAGE_TYPE_COMPOSER,
@@ -35,12 +36,10 @@ import {
CANCEL_DELETE_PACKAGE_FILE_TRACKING_ACTION,
SHOW_DELETE_SUCCESS_ALERT,
FETCH_PACKAGE_DETAILS_ERROR_MESSAGE,
- DELETE_PACKAGE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_ERROR_MESSAGE,
DELETE_PACKAGE_FILE_SUCCESS_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
-import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
import destroyPackageFileMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package_file.mutation.graphql';
import getPackageDetails from '~/packages_and_registries/package_registry/graphql/queries/get_package_details.query.graphql';
import Tracking from '~/tracking';
@@ -62,6 +61,7 @@ export default {
AdditionalMetadata,
InstallationCommands,
PackageFiles,
+ DeletePackage,
},
directives: {
GlTooltip: GlTooltipDirective,
@@ -148,40 +148,15 @@ export default {
formatSize(size) {
return numberToHumanSize(size);
},
- async deletePackage() {
- const { data } = await this.$apollo.mutate({
- mutation: destroyPackageMutation,
- variables: {
- id: this.packageEntity.id,
- },
- });
+ navigateToListWithSuccessModal() {
+ const returnTo =
+ !this.groupListUrl || document.referrer.includes(this.projectName)
+ ? this.projectListUrl
+ : this.groupListUrl; // to avoid security issue url are supplied from backend
- if (data?.destroyPackage?.errors[0]) {
- throw data.destroyPackage.errors[0];
- }
- },
- async confirmPackageDeletion() {
- this.track(DELETE_PACKAGE_TRACKING_ACTION);
-
- try {
- await this.deletePackage();
-
- const returnTo =
- !this.groupListUrl || document.referrer.includes(this.projectName)
- ? this.projectListUrl
- : this.groupListUrl; // to avoid security issue url are supplied from backend
-
- const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
+ const modalQuery = objectToQuery({ [SHOW_DELETE_SUCCESS_ALERT]: true });
- window.location.replace(`${returnTo}?${modalQuery}`);
- } catch (error) {
- createFlash({
- message: DELETE_PACKAGE_ERROR_MESSAGE,
- type: 'warning',
- captureError: true,
- error,
- });
- }
+ window.location.replace(`${returnTo}?${modalQuery}`);
},
async deletePackageFile(id) {
try {
@@ -322,26 +297,33 @@ export default {
</gl-tab>
</gl-tabs>
- <gl-modal
- ref="deleteModal"
- modal-id="delete-modal"
- data-testid="delete-modal"
- :action-primary="$options.modal.packageDeletePrimaryAction"
- :action-cancel="$options.modal.cancelAction"
- @primary="confirmPackageDeletion"
- @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
+ <delete-package
+ @start="track($options.trackingActions.DELETE_PACKAGE_TRACKING_ACTION)"
+ @end="navigateToListWithSuccessModal"
>
- <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
- <gl-sprintf :message="$options.i18n.deleteModalContent">
- <template #version>
- <strong>{{ packageEntity.version }}</strong>
- </template>
+ <template #default="{ deletePackage }">
+ <gl-modal
+ ref="deleteModal"
+ modal-id="delete-modal"
+ data-testid="delete-modal"
+ :action-primary="$options.modal.packageDeletePrimaryAction"
+ :action-cancel="$options.modal.cancelAction"
+ @primary="deletePackage(packageEntity)"
+ @canceled="track($options.trackingActions.CANCEL_DELETE_PACKAGE)"
+ >
+ <template #modal-title>{{ $options.i18n.deleteModalTitle }}</template>
+ <gl-sprintf :message="$options.i18n.deleteModalContent">
+ <template #version>
+ <strong>{{ packageEntity.version }}</strong>
+ </template>
- <template #name>
- <strong>{{ packageEntity.name }}</strong>
- </template>
- </gl-sprintf>
- </gl-modal>
+ <template #name>
+ <strong>{{ packageEntity.name }}</strong>
+ </template>
+ </gl-sprintf>
+ </gl-modal>
+ </template>
+ </delete-package>
<gl-modal
ref="deleteFileModal"
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue
new file mode 100644
index 00000000000..7a85fd3052e
--- /dev/null
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/functional/delete_package.vue
@@ -0,0 +1,62 @@
+<script>
+import destroyPackageMutation from '~/packages_and_registries/package_registry/graphql/mutations/destroy_package.mutation.graphql';
+import createFlash from '~/flash';
+import { s__ } from '~/locale';
+
+import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/package_registry/constants';
+
+export default {
+ props: {
+ refetchQueries: {
+ type: Array,
+ required: false,
+ default: null,
+ },
+ showSuccessAlert: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ i18n: {
+ errorMessage: s__('PackageRegistry|Something went wrong while deleting the package.'),
+ successMessage: DELETE_PACKAGE_SUCCESS_MESSAGE,
+ },
+ methods: {
+ async deletePackage(packageEntity) {
+ try {
+ this.$emit('start');
+ const { data } = await this.$apollo.mutate({
+ mutation: destroyPackageMutation,
+ variables: {
+ id: packageEntity.id,
+ },
+ awaitRefetchQueries: Boolean(this.refetchQueries),
+ refetchQueries: this.refetchQueries,
+ });
+
+ if (data?.destroyPackage?.errors[0]) {
+ throw data.destroyPackage.errors[0];
+ }
+ if (this.showSuccessAlert) {
+ createFlash({
+ message: this.$options.i18n.successMessage,
+ type: 'success',
+ });
+ }
+ } catch (error) {
+ createFlash({
+ message: this.$options.i18n.errorMessage,
+ type: 'warning',
+ captureError: true,
+ error,
+ });
+ }
+ this.$emit('end');
+ },
+ },
+ render() {
+ return this.$scopedSlots.default({ deletePackage: this.deletePackage });
+ },
+};
+</script>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
index 08481ac5655..11eeaf933ff 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/app.vue
@@ -1,33 +1,31 @@
<script>
-/*
- * The following component has several commented lines, this is because we are refactoring them piece by piece on several mrs
- * For a complete overview of the plan please check: https://gitlab.com/gitlab-org/gitlab/-/issues/330846
- * This work is behind feature flag: https://gitlab.com/gitlab-org/gitlab/-/issues/341136
- */
-// import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
+import { GlEmptyState, GlLink, GlSprintf } from '@gitlab/ui';
import createFlash from '~/flash';
import { historyReplaceState } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
-import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants';
import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants';
-import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
import {
PROJECT_RESOURCE_TYPE,
GROUP_RESOURCE_TYPE,
- LIST_QUERY_DEBOUNCE_TIME,
+ GRAPHQL_PAGE_SIZE,
+ DELETE_PACKAGE_SUCCESS_MESSAGE,
} from '~/packages_and_registries/package_registry/constants';
+import getPackagesQuery from '~/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql';
+
+import DeletePackage from '~/packages_and_registries/package_registry/components/functional/delete_package.vue';
import PackageTitle from './package_title.vue';
import PackageSearch from './package_search.vue';
-// import PackageList from './packages_list.vue';
+import PackageList from './packages_list.vue';
export default {
components: {
- // GlEmptyState,
- // GlLink,
- // GlSprintf,
- // PackageList,
+ GlEmptyState,
+ GlLink,
+ GlSprintf,
+ PackageList,
PackageTitle,
PackageSearch,
+ DeletePackage,
},
inject: [
'packageHelpUrl',
@@ -41,6 +39,7 @@ export default {
packages: {},
sort: '',
filters: {},
+ mutationLoading: false,
};
},
apollo: {
@@ -52,7 +51,9 @@ export default {
update(data) {
return data[this.graphqlResource].packages;
},
- debounce: LIST_QUERY_DEBOUNCE_TIME,
+ skip() {
+ return !this.sort;
+ },
},
},
computed: {
@@ -64,22 +65,40 @@ export default {
groupSort: this.isGroupPage ? this.sort : undefined,
packageName: this.filters?.packageName,
packageType: this.filters?.packageType,
+ first: GRAPHQL_PAGE_SIZE,
};
},
graphqlResource() {
return this.isGroupPage ? GROUP_RESOURCE_TYPE : PROJECT_RESOURCE_TYPE;
},
+ pageInfo() {
+ return this.packages?.pageInfo ?? {};
+ },
packagesCount() {
return this.packages?.count;
},
hasFilters() {
return this.filters.packageName && this.filters.packageType;
},
+ emptySearch() {
+ return !this.filters.packageName && !this.filters.packageType;
+ },
emptyStateTitle() {
return this.emptySearch
? this.$options.i18n.emptyPageTitle
: this.$options.i18n.noResultsTitle;
},
+ isLoading() {
+ return this.$apollo.queries.packages.loading || this.mutationLoading;
+ },
+ refetchQueriesData() {
+ return [
+ {
+ query: getPackagesQuery,
+ variables: this.queryVariables,
+ },
+ ];
+ },
},
mounted() {
this.checkDeleteAlert();
@@ -99,6 +118,35 @@ export default {
this.sort = sort;
this.filters = { ...filters };
},
+ updateQuery(_, { fetchMoreResult }) {
+ return fetchMoreResult;
+ },
+ fetchNextPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: GRAPHQL_PAGE_SIZE,
+ last: null,
+ after: this.pageInfo?.endCursor,
+ };
+
+ this.$apollo.queries.packages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ },
+ fetchPreviousPage() {
+ const variables = {
+ ...this.queryVariables,
+ first: null,
+ last: GRAPHQL_PAGE_SIZE,
+ before: this.pageInfo?.startCursor,
+ };
+
+ this.$apollo.queries.packages.fetchMore({
+ variables,
+ updateQuery: this.updateQuery,
+ });
+ },
},
i18n: {
widenFilters: s__('PackageRegistry|To widen your search, change or remove the filters above.'),
@@ -116,19 +164,35 @@ export default {
<package-title :help-url="packageHelpUrl" :count="packagesCount" />
<package-search @update="handleSearchUpdate" />
- <!-- <package-list @page:changed="onPageChanged" @package:delete="onPackageDeleteRequest">
- <template #empty-state>
- <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
- <template #description>
- <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
- <gl-sprintf v-else :message="$options.i18n.noResultsText">
- <template #noPackagesLink="{ content }">
- <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ <delete-package
+ :refetch-queries="refetchQueriesData"
+ show-success-alert
+ @start="mutationLoading = true"
+ @end="mutationLoading = false"
+ >
+ <template #default="{ deletePackage }">
+ <package-list
+ :list="packages.nodes"
+ :is-loading="isLoading"
+ :page-info="pageInfo"
+ @prev-page="fetchPreviousPage"
+ @next-page="fetchNextPage"
+ @package:delete="deletePackage"
+ >
+ <template #empty-state>
+ <gl-empty-state :title="emptyStateTitle" :svg-path="emptyListIllustration">
+ <template #description>
+ <gl-sprintf v-if="hasFilters" :message="$options.i18n.widenFilters" />
+ <gl-sprintf v-else :message="$options.i18n.noResultsText">
+ <template #noPackagesLink="{ content }">
+ <gl-link :href="emptyListHelpUrl" target="_blank">{{ content }}</gl-link>
+ </template>
+ </gl-sprintf>
</template>
- </gl-sprintf>
+ </gl-empty-state>
</template>
- </gl-empty-state>
+ </package-list>
</template>
- </package-list> -->
+ </delete-package>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
index 836df59ca58..3483d23e251 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_search.vue
@@ -1,6 +1,6 @@
<script>
import { s__ } from '~/locale';
-import { sortableFields } from '~/packages/list/utils';
+import { sortableFields } from '~/packages_and_registries/package_registry/utils';
import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
index 6e00a48586e..bf41c36e09b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_title.vue
@@ -1,6 +1,5 @@
<script>
-import { n__ } from '~/locale';
-import { LIST_INTRO_TEXT, LIST_TITLE_TEXT } from '~/packages/list/constants';
+import { n__, s__ } from '~/locale';
import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
@@ -29,11 +28,14 @@ export default {
return n__(`%d Package`, `%d Packages`, this.count);
},
infoMessages() {
- return [{ text: LIST_INTRO_TEXT, link: this.helpUrl }];
+ return [{ text: this.$options.i18n.LIST_INTRO_TEXT, link: this.helpUrl }];
},
},
i18n: {
- LIST_TITLE_TEXT,
+ LIST_TITLE_TEXT: s__('PackageRegistry|Package Registry'),
+ LIST_INTRO_TEXT: s__(
+ 'PackageRegistry|Publish and share packages for a variety of common package managers. %{docLinkStart}More information%{docLinkEnd}',
+ ),
},
};
</script>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
index 25bac687dbf..2a946544c2f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/packages_list.vue
@@ -1,82 +1,94 @@
<script>
-import { GlPagination, GlModal, GlSprintf } from '@gitlab/ui';
-import { mapState, mapGetters } from 'vuex';
+import { GlModal, GlSprintf, GlKeysetPagination } from '@gitlab/ui';
import { s__ } from '~/locale';
-import PackagesListRow from '~/packages/shared/components/package_list_row.vue';
+import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue';
-import { TrackingActions } from '~/packages/shared/constants';
-import { packageTypeToTrackCategory } from '~/packages/shared/utils';
+import {
+ DELETE_PACKAGE_TRACKING_ACTION,
+ REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
+ CANCEL_DELETE_PACKAGE_TRACKING_ACTION,
+} from '~/packages_and_registries/package_registry/constants';
+import { packageTypeToTrackCategory } from '~/packages_and_registries/package_registry/utils';
import Tracking from '~/tracking';
export default {
components: {
- GlPagination,
+ GlKeysetPagination,
GlModal,
GlSprintf,
PackagesListLoader,
PackagesListRow,
},
mixins: [Tracking.mixin()],
+ props: {
+ list: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ isLoading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ pageInfo: {
+ type: Object,
+ required: true,
+ },
+ },
+
data() {
return {
itemToBeDeleted: null,
};
},
computed: {
- ...mapState({
- perPage: (state) => state.pagination.perPage,
- totalItems: (state) => state.pagination.total,
- page: (state) => state.pagination.page,
- isGroupPage: (state) => state.config.isGroupPage,
- isLoading: 'isLoading',
- }),
- ...mapGetters({ list: 'getList' }),
- currentPage: {
- get() {
- return this.page;
- },
- set(value) {
- this.$emit('page:changed', value);
- },
- },
isListEmpty() {
return !this.list || this.list.length === 0;
},
- modalAction() {
- return s__('PackageRegistry|Delete package');
- },
deletePackageName() {
return this.itemToBeDeleted?.name ?? '';
},
tracking() {
const category = this.itemToBeDeleted
- ? packageTypeToTrackCategory(this.itemToBeDeleted.package_type)
+ ? packageTypeToTrackCategory(this.itemToBeDeleted.packageType)
: undefined;
return {
category,
};
},
+ showPagination() {
+ return this.pageInfo.hasPreviousPage || this.pageInfo.hasNextPage;
+ },
+ showDeleteModal: {
+ get() {
+ return Boolean(this.itemToBeDeleted);
+ },
+ set(value) {
+ if (!value) {
+ this.itemToBeDeleted = null;
+ }
+ },
+ },
},
methods: {
setItemToBeDeleted(item) {
this.itemToBeDeleted = { ...item };
- this.track(TrackingActions.REQUEST_DELETE_PACKAGE);
- this.$refs.packageListDeleteModal.show();
+ this.track(REQUEST_DELETE_PACKAGE_TRACKING_ACTION);
},
deleteItemConfirmation() {
this.$emit('package:delete', this.itemToBeDeleted);
- this.track(TrackingActions.DELETE_PACKAGE);
- this.itemToBeDeleted = null;
+ this.track(DELETE_PACKAGE_TRACKING_ACTION);
},
deleteItemCanceled() {
- this.track(TrackingActions.CANCEL_DELETE_PACKAGE);
- this.itemToBeDeleted = null;
+ this.track(CANCEL_DELETE_PACKAGE_TRACKING_ACTION);
},
},
i18n: {
deleteModalContent: s__(
'PackageRegistry|You are about to delete %{name}, this operation is irreversible, are you sure?',
),
+ modalAction: s__('PackageRegistry|Delete package'),
},
};
</script>
@@ -95,29 +107,29 @@ export default {
v-for="packageEntity in list"
:key="packageEntity.id"
:package-entity="packageEntity"
- :package-link="packageEntity._links.web_path"
- :is-group="isGroupPage"
@packageToDelete="setItemToBeDeleted"
/>
</div>
- <gl-pagination
- v-model="currentPage"
- :per-page="perPage"
- :total-items="totalItems"
- align="center"
- class="gl-w-full gl-mt-3"
- />
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-if="showPagination"
+ v-bind="pageInfo"
+ class="gl-mt-3"
+ @prev="$emit('prev-page')"
+ @next="$emit('next-page')"
+ />
+ </div>
<gl-modal
- ref="packageListDeleteModal"
+ v-model="showDeleteModal"
modal-id="confirm-delete-pacakge"
ok-variant="danger"
@ok="deleteItemConfirmation"
@cancel="deleteItemCanceled"
>
- <template #modal-title>{{ modalAction }}</template>
- <template #modal-ok>{{ modalAction }}</template>
+ <template #modal-title>{{ $options.i18n.modalAction }}</template>
+ <template #modal-ok>{{ $options.i18n.modalAction }}</template>
<gl-sprintf :message="$options.i18n.deleteModalContent">
<template #name>
<strong>{{ deletePackageName }}</strong>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
index 529a7893dfc..59354e77ee9 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
+++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/tokens/package_type_token.vue
@@ -1,6 +1,6 @@
<script>
import { GlFilteredSearchToken, GlFilteredSearchSuggestion } from '@gitlab/ui';
-import { PACKAGE_TYPES } from '~/packages/list/constants';
+import { PACKAGE_TYPES } from '~/packages_and_registries/package_registry/constants';
export default {
components: {
@@ -17,9 +17,9 @@ export default {
<gl-filtered-search-suggestion
v-for="(type, index) in $options.PACKAGE_TYPES"
:key="index"
- :value="type.type"
+ :value="type"
>
- {{ type.title }}
+ {{ type }}
</gl-filtered-search-suggestion>
</template>
</gl-filtered-search-token>
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/constants.js b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
index 6a88880fa90..9fd8880861c 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/constants.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/constants.js
@@ -1,4 +1,4 @@
-import { s__ } from '~/locale';
+import { s__, __ } from '~/locale';
export const PACKAGE_TYPE_CONAN = 'CONAN';
export const PACKAGE_TYPE_MAVEN = 'MAVEN';
@@ -59,16 +59,7 @@ export const TRACKING_ACTION_COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND =
export const TRACKING_ACTION_COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND =
'copy_composer_package_include_command';
-export const TrackingCategories = {
- [PACKAGE_TYPE_MAVEN]: 'MavenPackages',
- [PACKAGE_TYPE_NPM]: 'NpmPackages',
- [PACKAGE_TYPE_CONAN]: 'ConanPackages',
-};
-
export const SHOW_DELETE_SUCCESS_ALERT = 'showSuccessDeleteAlert';
-export const DELETE_PACKAGE_ERROR_MESSAGE = s__(
- 'PackageRegistry|Something went wrong while deleting the package.',
-);
export const DELETE_PACKAGE_FILE_ERROR_MESSAGE = s__(
'PackageRegistry|Something went wrong while deleting the package file.',
);
@@ -79,6 +70,8 @@ export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
'PackageRegistry|Failed to load the package data',
);
+export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
+
export const PACKAGE_ERROR_STATUS = 'ERROR';
export const PACKAGE_DEFAULT_STATUS = 'DEFAULT';
export const PACKAGE_HIDDEN_STATUS = 'HIDDEN';
@@ -92,4 +85,52 @@ export const INSTANCE_PACKAGE_ENDPOINT_TYPE = 'instance';
export const PROJECT_RESOURCE_TYPE = 'project';
export const GROUP_RESOURCE_TYPE = 'group';
-export const LIST_QUERY_DEBOUNCE_TIME = 50;
+export const GRAPHQL_PAGE_SIZE = 20;
+
+export const LIST_KEY_NAME = 'name';
+export const LIST_KEY_PROJECT = 'project_path';
+export const LIST_KEY_VERSION = 'version';
+export const LIST_KEY_PACKAGE_TYPE = 'type';
+export const LIST_KEY_CREATED_AT = 'created_at';
+
+export const LIST_LABEL_NAME = __('Name');
+export const LIST_LABEL_PROJECT = __('Project');
+export const LIST_LABEL_VERSION = __('Version');
+export const LIST_LABEL_PACKAGE_TYPE = __('Type');
+export const LIST_LABEL_CREATED_AT = __('Published');
+
+export const SORT_FIELDS = [
+ {
+ orderBy: LIST_KEY_NAME,
+ label: LIST_LABEL_NAME,
+ },
+ {
+ orderBy: LIST_KEY_PROJECT,
+ label: LIST_LABEL_PROJECT,
+ },
+ {
+ orderBy: LIST_KEY_VERSION,
+ label: LIST_LABEL_VERSION,
+ },
+ {
+ orderBy: LIST_KEY_PACKAGE_TYPE,
+ label: LIST_LABEL_PACKAGE_TYPE,
+ },
+ {
+ orderBy: LIST_KEY_CREATED_AT,
+ label: LIST_LABEL_CREATED_AT,
+ },
+];
+
+export const PACKAGE_TYPES = [
+ s__('PackageRegistry|Composer'),
+ s__('PackageRegistry|Conan'),
+ s__('PackageRegistry|Generic'),
+ s__('PackageRegistry|Maven'),
+ s__('PackageRegistry|npm'),
+ s__('PackageRegistry|NuGet'),
+ s__('PackageRegistry|PyPI'),
+ s__('PackageRegistry|RubyGems'),
+ s__('PackageRegistry|Debian'),
+ s__('PackageRegistry|Helm'),
+];
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
index f8cb5c516e2..21d6fbc9e1f 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/index.js
@@ -17,7 +17,6 @@ export const apolloProvider = new VueApollo({
cacheConfig: {
fragmentMatcher,
},
- assumeImmutableResults: true,
},
),
});
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
index 74e6de87866..e3115365f8b 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
+++ b/app/assets/javascripts/packages_and_registries/package_registry/graphql/queries/get_packages.query.graphql
@@ -1,4 +1,5 @@
#import "~/packages_and_registries/package_registry/graphql/fragments/package_data.fragment.graphql"
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getPackages(
$fullPath: ID!
@@ -7,21 +8,47 @@ query getPackages(
$groupSort: PackageGroupSort
$packageName: String
$packageType: PackageTypeEnum
+ $first: Int
+ $last: Int
+ $after: String
+ $before: String
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
- packages(sort: $sort, packageName: $packageName, packageType: $packageType) {
+ packages(
+ sort: $sort
+ packageName: $packageName
+ packageType: $packageType
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
count
nodes {
...PackageData
}
+ pageInfo {
+ ...PageInfo
+ }
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
- packages(sort: $groupSort, packageName: $packageName, packageType: $packageType) {
+ packages(
+ sort: $groupSort
+ packageName: $packageName
+ packageType: $packageType
+ after: $after
+ before: $before
+ first: $first
+ last: $last
+ ) {
count
nodes {
...PackageData
}
+ pageInfo {
+ ...PageInfo
+ }
}
}
}
diff --git a/app/assets/javascripts/packages_and_registries/package_registry/utils.js b/app/assets/javascripts/packages_and_registries/package_registry/utils.js
index ae886952c3e..4ff8edb8f66 100644
--- a/app/assets/javascripts/packages_and_registries/package_registry/utils.js
+++ b/app/assets/javascripts/packages_and_registries/package_registry/utils.js
@@ -1,3 +1,4 @@
+import { capitalize } from 'lodash';
import { s__ } from '~/locale';
import {
PACKAGE_TYPE_CONAN,
@@ -10,6 +11,8 @@ import {
PACKAGE_TYPE_GENERIC,
PACKAGE_TYPE_DEBIAN,
PACKAGE_TYPE_HELM,
+ LIST_KEY_PROJECT,
+ SORT_FIELDS,
} from './constants';
export const getPackageTypeLabel = (packageType) => {
@@ -38,3 +41,8 @@ export const getPackageTypeLabel = (packageType) => {
return null;
}
};
+
+export const packageTypeToTrackCategory = (type) => `UI::${capitalize(type)}Packages`;
+
+export const sortableFields = (isGroupPage) =>
+ SORT_FIELDS.filter((f) => f.orderBy !== LIST_KEY_PROJECT || isGroupPage);
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
index 2dbe36def0e..5815c6393a7 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
+++ b/app/assets/javascripts/packages_and_registries/settings/group/components/dependency_proxy_settings.vue
@@ -103,6 +103,7 @@ export default {
:disabled="isLoading"
:label="$options.i18n.label"
data-qa-selector="dependency_proxy_setting_toggle"
+ data-testid="dependency-proxy-setting-toggle"
/>
</div>
</template>
diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});
diff --git a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
index 16152eb81f6..56f95fa2c1f 100644
--- a/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
+++ b/app/assets/javascripts/packages_and_registries/settings/project/graphql/index.js
@@ -5,10 +5,5 @@ import createDefaultClient from '~/lib/graphql';
Vue.use(VueApollo);
export const apolloProvider = new VueApollo({
- defaultClient: createDefaultClient(
- {},
- {
- assumeImmutableResults: true,
- },
- ),
+ defaultClient: createDefaultClient(),
});