diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-13 12:08:49 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-02-13 12:08:49 +0000 |
commit | 1308dc5eb484ab0f8064989fc551ebdb4b1a7976 (patch) | |
tree | 614a93d9bf8df34ecfc25c02648329987fb21dde /app/assets/javascripts/registry | |
parent | f0707f413ce49b5712fca236b950acbec029be1e (diff) | |
download | gitlab-ce-1308dc5eb484ab0f8064989fc551ebdb4b1a7976.tar.gz |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/registry')
6 files changed, 691 insertions, 6 deletions
diff --git a/app/assets/javascripts/registry/explorer/components/group_empty_state.vue b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue new file mode 100644 index 00000000000..a29a9bd23c3 --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/group_empty_state.vue @@ -0,0 +1,39 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import { mapState } from 'vuex'; + +export default { + name: 'GroupEmptyState', + components: { + GlEmptyState, + GlSprintf, + GlLink, + }, + computed: { + ...mapState(['config']), + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images available in this group')" + :svg-path="config.noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text"> + <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/registry/explorer/components/project_empty_state.vue b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue new file mode 100644 index 00000000000..53853b4b9fb --- /dev/null +++ b/app/assets/javascripts/registry/explorer/components/project_empty_state.vue @@ -0,0 +1,113 @@ +<script> +import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import { mapState } from 'vuex'; + +export default { + name: 'ProjectEmptyState', + components: { + ClipboardButton, + GlEmptyState, + GlSprintf, + GlLink, + }, + computed: { + ...mapState(['config']), + dockerBuildCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker build -t ${this.config.repositoryUrl} .`; + }, + dockerPushCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker push ${this.config.repositoryUrl}`; + }, + dockerLoginCommand() { + // eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings + return `docker login ${this.config.registryHostUrlWithPort}`; + }, + }, +}; +</script> +<template> + <gl-empty-state + :title="s__('ContainerRegistry|There are no container images stored for this project')" + :svg-path="config.noContainersImage" + class="container-message" + > + <template #description> + <p class="js-no-container-images-text"> + <gl-sprintf + :message=" + s__(`ContainerRegistry|With the Container Registry, 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> + <h5>{{ s__('ContainerRegistry|Quick Start') }}</h5> + <p class="js-not-logged-in-to-registry-text"> + <gl-sprintf + :message=" + 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.`) + " + > + <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> + <div class="input-group append-bottom-10"> + <input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerLoginCommand" + :title="s__('ContainerRegistry|Copy login command')" + class="input-group-text" + /> + </span> + </div> + <p></p> + <p> + {{ + s__( + 'ContainerRegistry|You can add an image to this registry with the following commands:', + ) + }} + </p> + + <div class="input-group append-bottom-10"> + <input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerBuildCommand" + :title="s__('ContainerRegistry|Copy build command')" + class="input-group-text" + /> + </span> + </div> + + <div class="input-group"> + <input :value="dockerPushCommand" type="text" class="form-control monospace" readonly /> + <span class="input-group-append"> + <clipboard-button + :text="dockerPushCommand" + :title="s__('ContainerRegistry|Copy push command')" + class="input-group-text" + /> + </span> + </div> + </template> + </gl-empty-state> +</template> 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> diff --git a/app/assets/javascripts/registry/explorer/stores/actions.js b/app/assets/javascripts/registry/explorer/stores/actions.js index 7c06a12a5fc..25ff105ac53 100644 --- a/app/assets/javascripts/registry/explorer/stores/actions.js +++ b/app/assets/javascripts/registry/explorer/stores/actions.js @@ -45,11 +45,11 @@ export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) export const requestTagsList = ({ commit, dispatch }, { pagination = {}, id }) => { commit(types.SET_MAIN_LOADING, true); - const url = window.atob(id); + const { tags_path } = JSON.parse(window.atob(id)); const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; return axios - .get(url, { params: { page, per_page: perPage } }) + .get(tags_path, { params: { page, per_page: perPage } }) .then(({ data, headers }) => { dispatch('receiveTagsListSuccess', { data, headers }); }) diff --git a/app/assets/javascripts/registry/explorer/stores/mutations.js b/app/assets/javascripts/registry/explorer/stores/mutations.js index 186f36a759a..a2c6a11de20 100644 --- a/app/assets/javascripts/registry/explorer/stores/mutations.js +++ b/app/assets/javascripts/registry/explorer/stores/mutations.js @@ -5,6 +5,7 @@ export default { [types.SET_INITIAL_STATE](state, config) { state.config = { ...config, + isGroupPage: config.isGroupPage !== undefined, }; }, |