diff options
Diffstat (limited to 'app/assets/javascripts/registry/explorer/pages')
-rw-r--r-- | app/assets/javascripts/registry/explorer/pages/details.vue | 329 | ||||
-rw-r--r-- | app/assets/javascripts/registry/explorer/pages/list.vue | 211 |
2 files changed, 536 insertions, 4 deletions
diff --git a/app/assets/javascripts/registry/explorer/pages/details.vue b/app/assets/javascripts/registry/explorer/pages/details.vue index 6d32ba41eae..bff67bb8376 100644 --- a/app/assets/javascripts/registry/explorer/pages/details.vue +++ b/app/assets/javascripts/registry/explorer/pages/details.vue @@ -1,7 +1,332 @@ <script> -export default {}; +import { mapState, mapActions } from 'vuex'; +import { + GlTable, + GlFormCheckbox, + GlButton, + GlIcon, + GlTooltipDirective, + GlPagination, + GlModal, + GlLoadingIcon, + GlSprintf, + GlEmptyState, + GlResizeObserverDirective, +} from '@gitlab/ui'; +import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; +import { n__, s__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import Tracking from '~/tracking'; +import { + LIST_KEY_TAG, + LIST_KEY_IMAGE_ID, + LIST_KEY_SIZE, + LIST_KEY_LAST_UPDATED, + LIST_KEY_ACTIONS, + LIST_KEY_CHECKBOX, + LIST_LABEL_TAG, + LIST_LABEL_IMAGE_ID, + LIST_LABEL_SIZE, + LIST_LABEL_LAST_UPDATED, +} from '../constants'; + +export default { + components: { + GlTable, + GlFormCheckbox, + GlButton, + GlIcon, + ClipboardButton, + GlPagination, + GlModal, + GlLoadingIcon, + GlSprintf, + GlEmptyState, + }, + directives: { + GlTooltip: GlTooltipDirective, + GlResizeObserver: GlResizeObserverDirective, + }, + mixins: [timeagoMixin, Tracking.mixin()], + data() { + return { + selectedItems: [], + itemsToBeDeleted: [], + selectAllChecked: false, + modalDescription: null, + isDesktop: true, + }; + }, + computed: { + ...mapState(['tags', 'tagsPagination', 'isLoading', 'config']), + imageName() { + const { name } = JSON.parse(window.atob(this.$route.params.id)); + return name; + }, + fields() { + return [ + { key: LIST_KEY_CHECKBOX, label: '' }, + { key: LIST_KEY_TAG, label: LIST_LABEL_TAG }, + { key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID }, + { key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE }, + { key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED }, + { key: LIST_KEY_ACTIONS, label: '' }, + ].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop); + }, + isMultiDelete() { + return this.itemsToBeDeleted.length > 1; + }, + tracking() { + return { + label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete', + }; + }, + modalAction() { + return n__( + 'ContainerRegistry|Remove tag', + 'ContainerRegistry|Remove tags', + this.isMultiDelete ? this.itemsToBeDeleted.length : 1, + ); + }, + currentPage: { + get() { + return this.tagsPagination.page; + }, + set(page) { + this.requestTagsList({ pagination: { page }, id: this.$route.params.id }); + }, + }, + }, + methods: { + ...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']), + setModalDescription(itemIndex = -1) { + if (itemIndex === -1) { + this.modalDescription = { + message: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`), + item: this.itemsToBeDeleted.length, + }; + } else { + const { path } = this.tags[itemIndex]; + + this.modalDescription = { + message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`), + item: path, + }; + } + }, + formatSize(size) { + return numberToHumanSize(size); + }, + layers(layers) { + return layers ? n__('%d layer', '%d layers', layers) : ''; + }, + onSelectAllChange() { + if (this.selectAllChecked) { + this.deselectAll(); + } else { + this.selectAll(); + } + }, + selectAll() { + this.selectedItems = this.tags.map((x, index) => index); + this.selectAllChecked = true; + }, + deselectAll() { + this.selectedItems = []; + this.selectAllChecked = false; + }, + updateSelectedItems(index) { + const delIndex = this.selectedItems.findIndex(x => x === index); + + if (delIndex > -1) { + this.selectedItems.splice(delIndex, 1); + this.selectAllChecked = false; + } else { + this.selectedItems.push(index); + + if (this.selectedItems.length === this.tags.length) { + this.selectAllChecked = true; + } + } + }, + deleteSingleItem(index) { + this.setModalDescription(index); + this.itemsToBeDeleted = [index]; + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + deleteMultipleItems() { + this.itemsToBeDeleted = [...this.selectedItems]; + if (this.selectedItems.length === 1) { + this.setModalDescription(this.itemsToBeDeleted[0]); + } else if (this.selectedItems.length > 1) { + this.setModalDescription(); + } + this.track('click_button'); + this.$refs.deleteModal.show(); + }, + handleSingleDelete(itemToDelete) { + this.itemsToBeDeleted = []; + this.requestDeleteTag({ tag: itemToDelete, imageId: this.$route.params.id }); + }, + handleMultipleDelete() { + const { itemsToBeDeleted } = this; + this.itemsToBeDeleted = []; + this.selectedItems = []; + + this.requestDeleteTags({ + ids: itemsToBeDeleted.map(x => this.tags[x].name), + imageId: this.$route.params.id, + }); + }, + onDeletionConfirmed() { + this.track('confirm_delete'); + if (this.isMultiDelete) { + this.handleMultipleDelete(); + } else { + const index = this.itemsToBeDeleted[0]; + this.handleSingleDelete(this.tags[index]); + } + }, + handleResize() { + this.isDesktop = GlBreakpointInstance.isDesktop(); + }, + }, +}; </script> <template> - <div></div> + <div + v-gl-resize-observer="handleResize" + class="my-3 position-absolute w-100 slide-enter-to-element" + > + <div class="d-flex my-3 align-items-center"> + <h4> + <gl-sprintf :message="s__('ContainerRegistry|%{imageName} tags')"> + <template #imageName> + {{ imageName }} + </template> + </gl-sprintf> + </h4> + </div> + <gl-loading-icon v-if="isLoading" /> + <template v-else-if="tags.length > 0"> + <gl-table :items="tags" :fields="fields" :stacked="!isDesktop"> + <template v-if="isDesktop" #head(checkbox)> + <gl-form-checkbox + ref="mainCheckbox" + :checked="selectAllChecked" + @change="onSelectAllChange" + /> + </template> + <template #head(actions)> + <gl-button + ref="bulkDeleteButton" + v-gl-tooltip + :disabled="!selectedItems || selectedItems.length === 0" + class="float-right" + variant="danger" + :title="s__('ContainerRegistry|Remove selected tags')" + :aria-label="s__('ContainerRegistry|Remove selected tags')" + @click="deleteMultipleItems()" + > + <gl-icon name="remove" /> + </gl-button> + </template> + + <template #cell(checkbox)="{index}"> + <gl-form-checkbox + ref="rowCheckbox" + class="js-row-checkbox" + :checked="selectedItems.includes(index)" + @change="updateSelectedItems(index)" + /> + </template> + <template #cell(name)="{item}"> + <span ref="rowName"> + {{ item.name }} + </span> + <clipboard-button + v-if="item.location" + ref="rowClipboardButton" + :title="item.location" + :text="item.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </template> + <template #cell(short_revision)="{value}"> + <span ref="rowShortRevision"> + {{ value }} + </span> + </template> + <template #cell(total_size)="{item}"> + <span ref="rowSize"> + {{ formatSize(item.total_size) }} + <template v-if="item.total_size && item.layers"> + · + </template> + {{ layers(item.layers) }} + </span> + </template> + <template #cell(created_at)="{value}"> + <span ref="rowTime"> + {{ timeFormatted(value) }} + </span> + </template> + <template #cell(actions)="{index, item}"> + <gl-button + ref="singleDeleteButton" + :title="s__('ContainerRegistry|Remove tag')" + :aria-label="s__('ContainerRegistry|Remove tag')" + :disabled="!item.destroy_path" + variant="danger" + :class="['js-delete-registry float-right btn-inverted btn-border-color btn-icon']" + @click="deleteSingleItem(index)" + > + <gl-icon name="remove" /> + </gl-button> + </template> + </gl-table> + <gl-pagination + ref="pagination" + v-model="currentPage" + :per-page="tagsPagination.perPage" + :total-items="tagsPagination.total" + align="center" + class="w-100" + /> + <gl-modal + ref="deleteModal" + modal-id="delete-tag-modal" + ok-variant="danger" + @ok="onDeletionConfirmed" + @cancel="track('cancel_delete')" + > + <template #modal-title>{{ modalAction }}</template> + <template #modal-ok>{{ modalAction }}</template> + <p v-if="modalDescription"> + <gl-sprintf :message="modalDescription.message"> + <template #item> + <b>{{ modalDescription.item }}</b> + </template> + </gl-sprintf> + </p> + </gl-modal> + </template> + <gl-empty-state + v-else + :title="s__('ContainerRegistry|This image has no active tags')" + :svg-path="config.noContainersImage" + :description=" + 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.`, + ) + " + class="mx-auto my-0" + /> + </div> </template> diff --git a/app/assets/javascripts/registry/explorer/pages/list.vue b/app/assets/javascripts/registry/explorer/pages/list.vue index 6d32ba41eae..dc730ac2828 100644 --- a/app/assets/javascripts/registry/explorer/pages/list.vue +++ b/app/assets/javascripts/registry/explorer/pages/list.vue @@ -1,7 +1,214 @@ <script> -export default {}; +import { mapState, mapActions } from 'vuex'; +import { + GlLoadingIcon, + GlEmptyState, + GlPagination, + GlTooltipDirective, + GlButton, + GlIcon, + GlModal, + GlSprintf, + GlLink, +} from '@gitlab/ui'; +import Tracking from '~/tracking'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ProjectEmptyState from '../components/project_empty_state.vue'; +import GroupEmptyState from '../components/group_empty_state.vue'; + +export default { + name: 'RegistryListApp', + components: { + GlEmptyState, + GlLoadingIcon, + GlPagination, + ProjectEmptyState, + GroupEmptyState, + ClipboardButton, + GlButton, + GlIcon, + GlModal, + GlSprintf, + GlLink, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + mixins: [Tracking.mixin()], + data() { + return { + itemToDelete: {}, + }; + }, + computed: { + ...mapState(['config', 'isLoading', 'images', 'pagination']), + tracking() { + return { + label: 'registry_repository_delete', + }; + }, + currentPage: { + get() { + return this.pagination.page; + }, + set(page) { + this.requestImagesList({ page }); + }, + }, + }, + methods: { + ...mapActions(['requestImagesList', 'requestDeleteImage']), + deleteImage(item) { + // This event is already tracked in the system and so the name must be kept to aggregate the data + this.track('click_button'); + this.itemToDelete = item; + this.$refs.deleteModal.show(); + }, + handleDeleteRepository() { + this.track('confirm_delete'); + this.requestDeleteImage(this.itemToDelete.destroy_path); + this.itemToDelete = {}; + }, + encodeListItem(item) { + const params = JSON.stringify({ name: item.path, tags_path: item.tags_path }); + return window.btoa(params); + }, + }, +}; </script> <template> - <div></div> + <div class="position-absolute w-100 slide-enter-from-element"> + <gl-empty-state + v-if="config.characterError" + :title="s__('ContainerRegistry|Docker connection error')" + :svg-path="config.containersErrorImage" + > + <template #description> + <p> + <gl-sprintf + :message=" + s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an + issue with your project name or path. + %{docLinkStart}More Information%{docLinkEnd}`) + " + > + <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> + <gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" /> + + <template v-else> + <div v-if="images.length" ref="imagesList"> + <h4>{{ s__('ContainerRegistry|Container Registry') }}</h4> + <p> + <gl-sprintf + :message=" + s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every + project can have its own space to store its Docker images. + %{docLinkStart}More Information%{docLinkEnd}`) + " + > + <template #docLink="{content}"> + <gl-link :href="config.helpPagePath" target="_blank"> + {{ content }} + </gl-link> + </template> + </gl-sprintf> + </p> + + <div class="d-flex flex-column"> + <div + v-for="(listItem, index) in images" + :key="index" + ref="rowItem" + :class="[ + 'd-flex justify-content-between align-items-center py-2 border-bottom', + { 'border-top': index === 0 }, + ]" + > + <div> + <router-link + ref="detailsLink" + :to="{ name: 'details', params: { id: encodeListItem(listItem) } }" + > + {{ listItem.path }} + </router-link> + <clipboard-button + v-if="listItem.location" + ref="clipboardButton" + :text="listItem.location" + :title="listItem.location" + css-class="btn-default btn-transparent btn-clipboard" + /> + </div> + <div + v-gl-tooltip="{ disabled: listItem.destroy_path }" + class="d-none d-sm-block" + :title=" + s__( + 'ContainerRegistry|Missing or insufficient permission, delete button disabled', + ) + " + > + <gl-button + ref="deleteImageButton" + v-gl-tooltip + :disabled="!listItem.destroy_path" + :title="s__('ContainerRegistry|Remove repository')" + :aria-label="s__('ContainerRegistry|Remove repository')" + class="btn-inverted" + variant="danger" + @click="deleteImage(listItem)" + > + <gl-icon name="remove" /> + </gl-button> + </div> + </div> + </div> + <gl-pagination + v-model="currentPage" + :per-page="pagination.perPage" + :total-items="pagination.total" + align="center" + class="w-100 mt-2" + /> + </div> + <template v-else> + <project-empty-state v-if="!config.isGroupPage" /> + <group-empty-state v-else /> + </template> + </template> + + <gl-modal + ref="deleteModal" + modal-id="delete-image-modal" + ok-variant="danger" + @ok="handleDeleteRepository" + @cancel="track('cancel_delete')" + > + <template #modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template> + <p> + <gl-sprintf + :message=" s__( + 'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.', + )," + > + <template #title> + <b>{{ itemToDelete.path }}</b> + </template> + </gl-sprintf> + </p> + <template #modal-ok>{{ __('Remove') }}</template> + </gl-modal> + </template> + </div> </template> |