Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/packages_and_registries/container_registry/explorer/components')
-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
19 files changed, 1731 insertions, 0 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>