summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/registry/explorer/pages
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/registry/explorer/pages')
-rw-r--r--app/assets/javascripts/registry/explorer/pages/details.vue329
-rw-r--r--app/assets/javascripts/registry/explorer/pages/list.vue211
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">
+ &middot;
+ </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>