diff options
author | Grzegorz Bizon <grzegorz@gitlab.com> | 2017-10-04 07:58:34 +0000 |
---|---|---|
committer | Grzegorz Bizon <grzegorz@gitlab.com> | 2017-10-04 07:58:34 +0000 |
commit | c958c201b72577eb01662cc1dac0f253e3d3e6db (patch) | |
tree | 26f52060490aaee5c2695e34c57b812383d834a3 | |
parent | 912e6b0b3d887b86bf41066062376438d3377da7 (diff) | |
parent | ba4a442996d322ca736c97ff6e7f7fdf3860d36a (diff) | |
download | gitlab-ce-c958c201b72577eb01662cc1dac0f253e3d3e6db.tar.gz |
Merge branch '31050-registry-image-lists' into 'master'
Lazy load and paginate registry image list
Closes #31050
See merge request gitlab-org/gitlab-ce!14303
43 files changed, 1541 insertions, 155 deletions
diff --git a/app/assets/javascripts/registry/components/app.vue b/app/assets/javascripts/registry/components/app.vue new file mode 100644 index 00000000000..2d8ca443ea7 --- /dev/null +++ b/app/assets/javascripts/registry/components/app.vue @@ -0,0 +1,62 @@ +<script> + /* globals Flash */ + import { mapGetters, mapActions } from 'vuex'; + import '../../flash'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import store from '../stores'; + import collapsibleContainer from './collapsible_container.vue'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + name: 'registryListApp', + props: { + endpoint: { + type: String, + required: true, + }, + }, + store, + components: { + collapsibleContainer, + loadingIcon, + }, + computed: { + ...mapGetters([ + 'isLoading', + 'repos', + ]), + }, + methods: { + ...mapActions([ + 'setMainEndpoint', + 'fetchRepos', + ]), + }, + created() { + this.setMainEndpoint(this.endpoint); + }, + mounted() { + this.fetchRepos() + .catch(() => Flash(errorMessages[errorMessagesTypes.FETCH_REPOS])); + }, + }; +</script> +<template> + <div> + <loading-icon + v-if="isLoading" + size="3" + /> + + <collapsible-container + v-else-if="!isLoading && repos.length" + v-for="(item, index) in repos" + :key="index" + :repo="item" + /> + + <p v-else-if="!isLoading && !repos.length"> + {{__("No container images stored for this project. Add one by following the instructions above.")}} + </p> + </div> +</template> diff --git a/app/assets/javascripts/registry/components/collapsible_container.vue b/app/assets/javascripts/registry/components/collapsible_container.vue new file mode 100644 index 00000000000..41ea9742406 --- /dev/null +++ b/app/assets/javascripts/registry/components/collapsible_container.vue @@ -0,0 +1,131 @@ +<script> + /* globals Flash */ + import { mapActions } from 'vuex'; + import '../../flash'; + import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; + import loadingIcon from '../../vue_shared/components/loading_icon.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + import tableRegistry from './table_registry.vue'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + name: 'collapsibeContainerRegisty', + props: { + repo: { + type: Object, + required: true, + }, + }, + components: { + clipboardButton, + loadingIcon, + tableRegistry, + }, + directives: { + tooltip, + }, + data() { + return { + isOpen: false, + }; + }, + computed: { + clipboardText() { + return `docker pull ${this.repo.location}`; + }, + }, + methods: { + ...mapActions([ + 'fetchRepos', + 'fetchList', + 'deleteRepo', + ]), + + toggleRepo() { + this.isOpen = !this.isOpen; + + if (this.isOpen) { + this.fetchList({ repo: this.repo }) + .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY)); + } + }, + + handleDeleteRepository() { + this.deleteRepo(this.repo) + .then(() => this.fetchRepos()) + .catch(() => this.showError(errorMessagesTypes.DELETE_REPO)); + }, + + showError(message) { + Flash((errorMessages[message])); + }, + }, + }; +</script> + +<template> + <div class="container-image"> + <div + class="container-image-head"> + <button + type="button" + @click="toggleRepo" + class="js-toggle-repo btn-link"> + <i + class="fa" + :class="{ + 'fa-chevron-right': !isOpen, + 'fa-chevron-up': isOpen, + }" + aria-hidden="true"> + </i> + {{repo.name}} + </button> + + <clipboard-button + v-if="repo.location" + :text="clipboardText" + :title="repo.location" + /> + + <div class="controls hidden-xs pull-right"> + <button + v-if="repo.canDelete" + type="button" + class="js-remove-repo btn btn-danger" + :title="s__('ContainerRegistry|Remove repository')" + :aria-label="s__('ContainerRegistry|Remove repository')" + v-tooltip + @click="handleDeleteRepository"> + <i + class="fa fa-trash" + aria-hidden="true"> + </i> + </button> + </div> + + </div> + + <loading-icon + v-if="repo.isLoading" + class="append-bottom-20" + size="2" + /> + + <div + v-else-if="!repo.isLoading && isOpen" + class="container-image-tags"> + + <table-registry + v-if="repo.list.length" + :repo="repo" + /> + + <div + v-else + class="nothing-here-block"> + {{s__("ContainerRegistry|No tags in Container Registry for this container image.")}} + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/registry/components/table_registry.vue b/app/assets/javascripts/registry/components/table_registry.vue new file mode 100644 index 00000000000..4ce1571b0aa --- /dev/null +++ b/app/assets/javascripts/registry/components/table_registry.vue @@ -0,0 +1,137 @@ +<script> + /* globals Flash */ + import { mapActions } from 'vuex'; + import { n__ } from '../../locale'; + import '../../flash'; + import clipboardButton from '../../vue_shared/components/clipboard_button.vue'; + import tablePagination from '../../vue_shared/components/table_pagination.vue'; + import tooltip from '../../vue_shared/directives/tooltip'; + import timeagoMixin from '../../vue_shared/mixins/timeago'; + import { errorMessages, errorMessagesTypes } from '../constants'; + + export default { + props: { + repo: { + type: Object, + required: true, + }, + }, + components: { + clipboardButton, + tablePagination, + }, + mixins: [ + timeagoMixin, + ], + directives: { + tooltip, + }, + computed: { + shouldRenderPagination() { + return this.repo.pagination.total > this.repo.pagination.perPage; + }, + }, + methods: { + ...mapActions([ + 'fetchList', + 'deleteRegistry', + ]), + + layers(item) { + return item.layers ? n__('%d layer', '%d layers', item.layers) : ''; + }, + + handleDeleteRegistry(registry) { + this.deleteRegistry(registry) + .then(() => this.fetchList({ repo: this.repo })) + .catch(() => this.showError(errorMessagesTypes.DELETE_REGISTRY)); + }, + + onPageChange(pageNumber) { + this.fetchList({ repo: this.repo, page: pageNumber }) + .catch(() => this.showError(errorMessagesTypes.FETCH_REGISTRY)); + }, + + clipboardText(text) { + return `docker pull ${text}`; + }, + + showError(message) { + Flash((errorMessages[message])); + }, + }, + }; +</script> +<template> +<div> + <table class="table tags"> + <thead> + <tr> + <th>{{s__('ContainerRegistry|Tag')}}</th> + <th>{{s__('ContainerRegistry|Tag ID')}}</th> + <th>{{s__("ContainerRegistry|Size")}}</th> + <th>{{s__("ContainerRegistry|Created")}}</th> + <th></th> + </tr> + </thead> + <tbody> + <tr + v-for="(item, i) in repo.list" + :key="i"> + <td> + + {{item.tag}} + + <clipboard-button + v-if="item.location" + :title="item.location" + :text="clipboardText(item.location)" + /> + </td> + <td> + <span + v-tooltip + :title="item.revision" + data-placement="bottom"> + {{item.shortRevision}} + </span> + </td> + <td> + {{item.size}} + <template v-if="item.size && item.layers"> + · + </template> + {{layers(item)}} + </td> + + <td> + {{timeFormated(item.createdAt)}} + </td> + + <td class="content"> + <button + v-if="item.canDelete" + type="button" + class="js-delete-registry btn btn-danger hidden-xs pull-right" + :title="s__('ContainerRegistry|Remove tag')" + :aria-label="s__('ContainerRegistry|Remove tag')" + data-container="body" + v-tooltip + @click="handleDeleteRegistry(item)"> + <i + class="fa fa-trash" + aria-hidden="true"> + </i> + </button> + </td> + </tr> + </tbody> + </table> + + <table-pagination + v-if="shouldRenderPagination" + :change="onPageChange" + :page-info="repo.pagination" + /> +</div> +</template> diff --git a/app/assets/javascripts/registry/constants.js b/app/assets/javascripts/registry/constants.js new file mode 100644 index 00000000000..712b0fade3d --- /dev/null +++ b/app/assets/javascripts/registry/constants.js @@ -0,0 +1,15 @@ +import { __ } from '../locale'; + +export const errorMessagesTypes = { + FETCH_REGISTRY: 'FETCH_REGISTRY', + FETCH_REPOS: 'FETCH_REPOS', + DELETE_REPO: 'DELETE_REPO', + DELETE_REGISTRY: 'DELETE_REGISTRY', +}; + +export const errorMessages = { + [errorMessagesTypes.FETCH_REGISTRY]: __('Something went wrong while fetching the registry list.'), + [errorMessagesTypes.FETCH_REPOS]: __('Something went wrong while fetching the projects.'), + [errorMessagesTypes.DELETE_REPO]: __('Something went wrong on our end.'), + [errorMessagesTypes.DELETE_REGISTRY]: __('Something went wrong on our end.'), +}; diff --git a/app/assets/javascripts/registry/index.js b/app/assets/javascripts/registry/index.js new file mode 100644 index 00000000000..d8edff73f72 --- /dev/null +++ b/app/assets/javascripts/registry/index.js @@ -0,0 +1,25 @@ +import Vue from 'vue'; +import registryApp from './components/app.vue'; +import Translate from '../vue_shared/translate'; + +Vue.use(Translate); + +document.addEventListener('DOMContentLoaded', () => new Vue({ + el: '#js-vue-registry-images', + components: { + registryApp, + }, + data() { + const dataset = document.querySelector(this.$options.el).dataset; + return { + endpoint: dataset.endpoint, + }; + }, + render(createElement) { + return createElement('registry-app', { + props: { + endpoint: this.endpoint, + }, + }); + }, +})); diff --git a/app/assets/javascripts/registry/stores/actions.js b/app/assets/javascripts/registry/stores/actions.js new file mode 100644 index 00000000000..34ed40b8b65 --- /dev/null +++ b/app/assets/javascripts/registry/stores/actions.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import * as types from './mutation_types'; + +Vue.use(VueResource); + +export const fetchRepos = ({ commit, state }) => { + commit(types.TOGGLE_MAIN_LOADING); + + return Vue.http.get(state.endpoint) + .then(res => res.json()) + .then((response) => { + commit(types.TOGGLE_MAIN_LOADING); + commit(types.SET_REPOS_LIST, response); + }); +}; + +export const fetchList = ({ commit }, { repo, page }) => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + + return Vue.http.get(repo.tagsPath, { params: { page } }) + .then((response) => { + const headers = response.headers; + + return response.json().then((resp) => { + commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo); + commit(types.SET_REGISTRY_LIST, { repo, resp, headers }); + }); + }); +}; + +export const deleteRepo = ({ commit }, repo) => Vue.http.delete(repo.destroyPath) + .then(res => res.json()); + +export const deleteRegistry = ({ commit }, image) => Vue.http.delete(image.destroyPath) + .then(res => res.json()); + +export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data); +export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING); diff --git a/app/assets/javascripts/registry/stores/getters.js b/app/assets/javascripts/registry/stores/getters.js new file mode 100644 index 00000000000..588f479c492 --- /dev/null +++ b/app/assets/javascripts/registry/stores/getters.js @@ -0,0 +1,2 @@ +export const isLoading = state => state.isLoading; +export const repos = state => state.repos; diff --git a/app/assets/javascripts/registry/stores/index.js b/app/assets/javascripts/registry/stores/index.js new file mode 100644 index 00000000000..78b67881210 --- /dev/null +++ b/app/assets/javascripts/registry/stores/index.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; +import Vuex from 'vuex'; +import * as actions from './actions'; +import * as getters from './getters'; +import mutations from './mutations'; + +Vue.use(Vuex); + +export default new Vuex.Store({ + state: { + isLoading: false, + endpoint: '', // initial endpoint to fetch the repos list + /** + * Each object in `repos` has the following strucure: + * { + * name: String, + * isLoading: Boolean, + * tagsPath: String // endpoint to request the list + * destroyPath: String // endpoit to delete the repo + * list: Array // List of the registry images + * } + * + * Each registry image inside `list` has the following structure: + * { + * tag: String, + * revision: String + * shortRevision: String + * size: Number + * layers: Number + * createdAt: String + * destroyPath: String // endpoit to delete each image + * } + */ + repos: [], + }, + actions, + getters, + mutations, +}); diff --git a/app/assets/javascripts/registry/stores/mutation_types.js b/app/assets/javascripts/registry/stores/mutation_types.js new file mode 100644 index 00000000000..2c69bf11807 --- /dev/null +++ b/app/assets/javascripts/registry/stores/mutation_types.js @@ -0,0 +1,7 @@ +export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT'; + +export const SET_REPOS_LIST = 'SET_REPOS_LIST'; +export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING'; + +export const SET_REGISTRY_LIST = 'SET_REGISTRY_LIST'; +export const TOGGLE_REGISTRY_LIST_LOADING = 'TOGGLE_REGISTRY_LIST_LOADING'; diff --git a/app/assets/javascripts/registry/stores/mutations.js b/app/assets/javascripts/registry/stores/mutations.js new file mode 100644 index 00000000000..e40382e7afc --- /dev/null +++ b/app/assets/javascripts/registry/stores/mutations.js @@ -0,0 +1,54 @@ +import * as types from './mutation_types'; +import { parseIntPagination, normalizeHeaders } from '../../lib/utils/common_utils'; + +export default { + + [types.SET_MAIN_ENDPOINT](state, endpoint) { + Object.assign(state, { endpoint }); + }, + + [types.SET_REPOS_LIST](state, list) { + Object.assign(state, { + repos: list.map(el => ({ + canDelete: !!el.destroy_path, + destroyPath: el.destroy_path, + id: el.id, + isLoading: false, + list: [], + location: el.location, + name: el.path, + tagsPath: el.tags_path, + })), + }); + }, + + [types.TOGGLE_MAIN_LOADING](state) { + Object.assign(state, { isLoading: !state.isLoading }); + }, + + [types.SET_REGISTRY_LIST](state, { repo, resp, headers }) { + const listToUpdate = state.repos.find(el => el.id === repo.id); + + const normalizedHeaders = normalizeHeaders(headers); + const pagination = parseIntPagination(normalizedHeaders); + + listToUpdate.pagination = pagination; + + listToUpdate.list = resp.map(element => ({ + tag: element.name, + revision: element.revision, + shortRevision: element.short_revision, + size: element.size, + layers: element.layers, + location: element.location, + createdAt: element.created_at, + destroyPath: element.destroy_path, + canDelete: !!element.destroy_path, + })); + }, + + [types.TOGGLE_REGISTRY_LIST_LOADING](state, list) { + const listToUpdate = state.repos.find(el => el.id === list.id); + listToUpdate.isLoading = !listToUpdate.isLoading; + }, +}; diff --git a/app/assets/javascripts/vue_shared/components/clipboard_button.vue b/app/assets/javascripts/vue_shared/components/clipboard_button.vue new file mode 100644 index 00000000000..3a7143c450e --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/clipboard_button.vue @@ -0,0 +1,32 @@ +<script> + /** + * Falls back to the code used in `copy_to_clipboard.js` + */ + + export default { + name: 'clipboardButton', + props: { + text: { + type: String, + required: true, + }, + title: { + type: String, + required: true, + }, + }, + }; +</script> + +<template> + <button + type="button" + class="btn btn-transparent btn-clipboard" + :data-title="title" + :data-clipboard-text="text"> + <i + aria-hidden="true" + class="fa fa-clipboard"> + </i> + </button> +</template> diff --git a/app/assets/stylesheets/pages/container_registry.scss b/app/assets/stylesheets/pages/container_registry.scss index 3266714396e..dfff3e15556 100644 --- a/app/assets/stylesheets/pages/container_registry.scss +++ b/app/assets/stylesheets/pages/container_registry.scss @@ -9,6 +9,14 @@ .container-image-head { padding: 0 16px; line-height: 4em; + + .btn-link { + padding: 0; + + &:focus { + outline: none; + } + } } .table.tags { diff --git a/app/assets/stylesheets/pages/settings_ci_cd.scss b/app/assets/stylesheets/pages/settings_ci_cd.scss index fe22d186af1..a355e2dee24 100644 --- a/app/assets/stylesheets/pages/settings_ci_cd.scss +++ b/app/assets/stylesheets/pages/settings_ci_cd.scss @@ -12,3 +12,7 @@ margin-left: 10px; } } + +.registry-placeholder { + min-height: 60px; +} diff --git a/app/controllers/projects/registry/repositories_controller.rb b/app/controllers/projects/registry/repositories_controller.rb index 71e7dc70a4d..32c0fc6d14a 100644 --- a/app/controllers/projects/registry/repositories_controller.rb +++ b/app/controllers/projects/registry/repositories_controller.rb @@ -6,17 +6,26 @@ module Projects def index @images = project.container_repositories + + respond_to do |format| + format.html + format.json do + render json: ContainerRepositoriesSerializer + .new(project: project, current_user: current_user) + .represent(@images) + end + end end def destroy if image.destroy - redirect_to project_container_registry_index_path(@project), - status: 302, - notice: 'Image repository has been removed successfully!' + respond_to do |format| + format.json { head :no_content } + end else - redirect_to project_container_registry_index_path(@project), - status: 302, - alert: 'Failed to remove image repository!' + respond_to do |format| + format.json { head :bad_request } + end end end diff --git a/app/controllers/projects/registry/tags_controller.rb b/app/controllers/projects/registry/tags_controller.rb index ae72bd03cfb..e602aa3f393 100644 --- a/app/controllers/projects/registry/tags_controller.rb +++ b/app/controllers/projects/registry/tags_controller.rb @@ -3,20 +3,35 @@ module Projects class TagsController < ::Projects::Registry::ApplicationController before_action :authorize_update_container_image!, only: [:destroy] + def index + respond_to do |format| + format.json do + render json: ContainerTagsSerializer + .new(project: @project, current_user: @current_user) + .with_pagination(request, response) + .represent(tags) + end + end + end + def destroy if tag.delete - redirect_to project_container_registry_index_path(@project), - status: 302, - notice: 'Registry tag has been removed successfully!' + respond_to do |format| + format.json { head :no_content } + end else - redirect_to project_container_registry_index_path(@project), - status: 302, - alert: 'Failed to remove registry tag!' + respond_to do |format| + format.json { head :bad_request } + end end end private + def tags + Kaminari::PaginatableArray.new(image.tags, limit: 15) + end + def image @image ||= project.container_repositories .find(params[:repository_id]) diff --git a/app/serializers/container_repositories_serializer.rb b/app/serializers/container_repositories_serializer.rb new file mode 100644 index 00000000000..56dc70b5687 --- /dev/null +++ b/app/serializers/container_repositories_serializer.rb @@ -0,0 +1,3 @@ +class ContainerRepositoriesSerializer < BaseSerializer + entity ContainerRepositoryEntity +end diff --git a/app/serializers/container_repository_entity.rb b/app/serializers/container_repository_entity.rb new file mode 100644 index 00000000000..1103cf30a07 --- /dev/null +++ b/app/serializers/container_repository_entity.rb @@ -0,0 +1,25 @@ +class ContainerRepositoryEntity < Grape::Entity + include RequestAwareEntity + + expose :id, :path, :location + + expose :tags_path do |repository| + project_registry_repository_tags_path(project, repository, format: :json) + end + + expose :destroy_path, if: -> (*) { can_destroy? } do |repository| + project_container_registry_path(project, repository, format: :json) + end + + private + + alias_method :repository, :object + + def project + request.project + end + + def can_destroy? + can?(request.current_user, :update_container_image, project) + end +end diff --git a/app/serializers/container_tag_entity.rb b/app/serializers/container_tag_entity.rb new file mode 100644 index 00000000000..ec1fc349586 --- /dev/null +++ b/app/serializers/container_tag_entity.rb @@ -0,0 +1,23 @@ +class ContainerTagEntity < Grape::Entity + include RequestAwareEntity + + expose :name, :location, :revision, :total_size, :created_at + + expose :destroy_path, if: -> (*) { can_destroy? } do |tag| + project_registry_repository_tag_path(project, tag.repository, tag.name, format: :json) + end + + private + + alias_method :tag, :object + + def project + request.project + end + + def can_destroy? + # TODO: We check permission against @project, not tag, + # as tag is no AR object that is attached to project + can?(request.current_user, :update_container_image, project) + end +end diff --git a/app/serializers/container_tags_serializer.rb b/app/serializers/container_tags_serializer.rb new file mode 100644 index 00000000000..6ff3adff135 --- /dev/null +++ b/app/serializers/container_tags_serializer.rb @@ -0,0 +1,17 @@ +class ContainerTagsSerializer < BaseSerializer + entity ContainerTagEntity + + def with_pagination(request, response) + tap { @paginator = Gitlab::Serializer::Pagination.new(request, response) } + end + + def paginated? + @paginator.present? + end + + def represent(resource, opts = {}) + resource = @paginator.paginate(resource) if paginated? + + super(resource, opts) + end +end diff --git a/app/views/projects/registry/repositories/_image.html.haml b/app/views/projects/registry/repositories/_image.html.haml deleted file mode 100644 index a0535edafc3..00000000000 --- a/app/views/projects/registry/repositories/_image.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -.container-image.js-toggle-container - .container-image-head - = link_to "#", class: "js-toggle-button" do - = icon('chevron-down', 'aria-hidden': 'true') - = escape_once(image.path) - - = clipboard_button(clipboard_text: "docker pull #{image.location}") - - - if can?(current_user, :update_container_image, @project) - .controls.hidden-xs.pull-right - = link_to project_container_registry_path(@project, image), - class: 'btn btn-remove has-tooltip', - title: 'Remove repository', - data: { confirm: 'Are you sure?' }, - method: :delete do - = icon('trash cred', 'aria-hidden': 'true') - - .container-image-tags.js-toggle-content.hide - - if image.has_tags? - .table-holder - %table.table.tags - %thead - %tr - %th Tag - %th Tag ID - %th Size - %th Created - - if can?(current_user, :update_container_image, @project) - %th - = render partial: 'tag', collection: image.tags - - else - .nothing-here-block No tags in Container Registry for this container image. diff --git a/app/views/projects/registry/repositories/index.html.haml b/app/views/projects/registry/repositories/index.html.haml index 5661af01302..36ea5e013e4 100644 --- a/app/views/projects/registry/repositories/index.html.haml +++ b/app/views/projects/registry/repositories/index.html.haml @@ -1,60 +1,49 @@ - page_title "Container Registry" -.row.prepend-top-default.append-bottom-default - .col-lg-3 - %h4.prepend-top-0 +%section + .settings-header + %h4 = page_title %p - With the Docker Container Registry integrated into GitLab, every project - can have its own space to store its Docker images. + = s_('ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images.') %p.append-bottom-0 = succeed '.' do - Learn more about - = link_to 'Container Registry', help_page_path('user/project/container_registry'), target: '_blank' + = s_('ContainerRegistry|Learn more about') + = link_to _('Container Registry'), help_page_path('user/project/container_registry'), target: '_blank' + .row.registry-placeholder.prepend-bottom-10 + .col-lg-12 + #js-vue-registry-images{ data: { endpoint: project_container_registry_index_path(@project, format: :json) } } - .col-lg-9 - .panel.panel-default - .panel-heading - %h4.panel-title - How to use the Container Registry - .panel-body - %p - First log in to GitLab’s Container Registry using your GitLab username - and password. If you have - = link_to '2FA enabled', help_page_path('user/profile/account/two_factor_authentication'), target: '_blank' - you need to use a - = succeed ':' do - = link_to 'personal access token', help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank' - %pre - docker login #{Gitlab.config.registry.host_port} - %br - %p - Once you log in, you’re free to create and upload a container image - using the common - %code build - and - %code push - commands: - %pre - :plain - docker build -t #{escape_once(@project.container_registry_url)} . - docker push #{escape_once(@project.container_registry_url)} + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('registry_list') - %hr - %h5.prepend-top-default - Use different image names - %p.light - GitLab supports up to 3 levels of image names. The following - examples of images are valid for your project: - %pre - :plain - #{escape_once(@project.container_registry_url)}:tag - #{escape_once(@project.container_registry_url)}/optional-image-name:tag - #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag - - - if @images.blank? - %p.settings-message.text-center.append-bottom-default - No container images stored for this project. Add one by following the - instructions above. - - else - = render partial: 'image', collection: @images + .row.prepend-top-10 + .col-lg-12 + .panel.panel-default + .panel-heading + %h4.panel-title + = s_('ContainerRegistry|How to use the Container Registry') + .panel-body + %p + - link_token = link_to(_('personal access token'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'personal-access-tokens'), target: '_blank') + - link_2fa = link_to(_('2FA enabled'), help_page_path('user/profile/account/two_factor_authentication'), target: '_blank') + = s_('ContainerRegistry|First log in to GitLab’s Container Registry using your GitLab username and password. If you have %{link_2fa} you need to use a %{link_token}:').html_safe % { link_2fa: link_2fa, link_token: link_token } + %pre + docker login #{Gitlab.config.registry.host_port} + %br + %p + = s_('ContainerRegistry|Once you log in, you’re free to create and upload a container image using the common %{build} and %{push} commands').html_safe % { build: "<code>build</code>".html_safe, push: "<code>push</code>".html_safe } + %pre + :plain + docker build -t #{escape_once(@project.container_registry_url)} . + docker push #{escape_once(@project.container_registry_url)} + %hr + %h5.prepend-top-default + = s_('ContainerRegistry|Use different image names') + %p.light + = s_('ContainerRegistry|GitLab supports up to 3 levels of image names. The following examples of images are valid for your project:') + %pre + :plain + #{escape_once(@project.container_registry_url)}:tag + #{escape_once(@project.container_registry_url)}/optional-image-name:tag + #{escape_once(@project.container_registry_url)}/optional-name/optional-image-name:tag diff --git a/config/routes/project.rb b/config/routes/project.rb index b36d13888cd..70d7673250c 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -271,7 +271,7 @@ constraints(ProjectUrlConstrainer.new) do namespace :registry do resources :repository, only: [] do - resources :tags, only: [:destroy], + resources :tags, only: [:index, :destroy], constraints: { id: Gitlab::Regex.container_registry_tag_regex } end end diff --git a/config/webpack.config.js b/config/webpack.config.js index 3404715fe30..2eb4444916b 100644 --- a/config/webpack.config.js +++ b/config/webpack.config.js @@ -68,6 +68,7 @@ var config = { prometheus_metrics: './prometheus_metrics', protected_branches: './protected_branches', protected_tags: './protected_tags', + registry_list: './registry/index.js', repo: './repo/index.js', sidebar: './sidebar/sidebar_bundle.js', schedule_form: './pipeline_schedules/pipeline_schedule_form_bundle.js', @@ -200,6 +201,7 @@ var config = { 'pdf_viewer', 'pipelines', 'pipelines_details', + 'registry_list', 'repo', 'schedule_form', 'schedules_index', diff --git a/spec/controllers/projects/registry/repositories_controller_spec.rb b/spec/controllers/projects/registry/repositories_controller_spec.rb index 2805968dcd9..5d9d5351687 100644 --- a/spec/controllers/projects/registry/repositories_controller_spec.rb +++ b/spec/controllers/projects/registry/repositories_controller_spec.rb @@ -42,6 +42,13 @@ describe Projects::Registry::RepositoriesController do expect { go_to_index }.to change { ContainerRepository.all.count }.by(1) expect(ContainerRepository.first).to be_root_repository end + + it 'json has a list of projects' do + go_to_index(format: :json) + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('registry/repositories') + end end context 'when there are no tags for this repository' do @@ -58,6 +65,31 @@ describe Projects::Registry::RepositoriesController do it 'does not ensure root container repository' do expect { go_to_index }.not_to change { ContainerRepository.all.count } end + + it 'responds with json if asked' do + go_to_index(format: :json) + + expect(response).to have_http_status(:ok) + expect(json_response).to be_kind_of(Array) + end + end + end + end + + describe 'DELETE destroy' do + context 'when root container repository exists' do + let!(:repository) do + create(:container_repository, :root, project: project) + end + + before do + stub_container_registry_tags(repository: :any, tags: []) + end + + it 'deletes a repository' do + expect { delete_repository(repository) }.to change { ContainerRepository.all.count }.by(-1) + + expect(response).to have_http_status(:no_content) end end end @@ -77,8 +109,16 @@ describe Projects::Registry::RepositoriesController do end end - def go_to_index + def go_to_index(format: :html) get :index, namespace_id: project.namespace, - project_id: project + project_id: project, + format: format + end + + def delete_repository(repository) + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: repository, + format: :json end end diff --git a/spec/controllers/projects/registry/tags_controller_spec.rb b/spec/controllers/projects/registry/tags_controller_spec.rb index f4af3587d23..bb702ebeb23 100644 --- a/spec/controllers/projects/registry/tags_controller_spec.rb +++ b/spec/controllers/projects/registry/tags_controller_spec.rb @@ -4,24 +4,83 @@ describe Projects::Registry::TagsController do let(:user) { create(:user) } let(:project) { create(:project, :private) } + let(:repository) do + create(:container_repository, name: 'image', project: project) + end + before do sign_in(user) stub_container_registry_config(enabled: true) end - context 'when user has access to registry' do + describe 'GET index' do + let(:tags) do + Array.new(40) { |i| "tag#{i}" } + end + before do - project.add_developer(user) + stub_container_registry_tags(repository: /image/, tags: tags) end - describe 'POST destroy' do + context 'when user can control the registry' do + before do + project.add_developer(user) + end + + it 'receive a list of tags' do + get_tags + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('registry/tags') + expect(response).to include_pagination_headers + end + end + + context 'when user can read the registry' do + before do + project.add_reporter(user) + end + + it 'receive a list of tags' do + get_tags + + expect(response).to have_http_status(:ok) + expect(response).to match_response_schema('registry/tags') + expect(response).to include_pagination_headers + end + end + + context 'when user does not have access to registry' do + before do + project.add_guest(user) + end + + it 'does not receive a list of tags' do + get_tags + + expect(response).to have_http_status(:not_found) + end + end + + private + + def get_tags + get :index, namespace_id: project.namespace, + project_id: project, + repository_id: repository, + format: :json + end + end + + describe 'POST destroy' do + context 'when user has access to registry' do + before do + project.add_developer(user) + end + context 'when there is matching tag present' do before do - stub_container_registry_tags(repository: /image/, tags: %w[rc1 test.]) - end - - let(:repository) do - create(:container_repository, name: 'image', project: project) + stub_container_registry_tags(repository: repository.path, tags: %w[rc1 test.]) end it 'makes it possible to delete regular tag' do @@ -37,12 +96,15 @@ describe Projects::Registry::TagsController do end end end - end - def destroy_tag(name) - post :destroy, namespace_id: project.namespace, - project_id: project, - repository_id: repository, - id: name + private + + def destroy_tag(name) + post :destroy, namespace_id: project.namespace, + project_id: project, + repository_id: repository, + id: name, + format: :json + end end end diff --git a/spec/features/container_registry_spec.rb b/spec/features/container_registry_spec.rb index ae39ba4da6b..45213dc6995 100644 --- a/spec/features/container_registry_spec.rb +++ b/spec/features/container_registry_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe "Container Registry" do +describe "Container Registry", js: true do let(:user) { create(:user) } let(:project) { create(:project) } @@ -41,16 +41,19 @@ describe "Container Registry" do expect_any_instance_of(ContainerRepository) .to receive(:delete_tags!).and_return(true) - click_on 'Remove repository' + click_on(class: 'js-remove-repo') end scenario 'user removes a specific tag from container repository' do visit_container_registry + find('.js-toggle-repo').trigger('click') + wait_for_requests + expect_any_instance_of(ContainerRegistry::Tag) .to receive(:delete).and_return(true) - click_on 'Remove tag' + click_on(class: 'js-delete-registry') end end diff --git a/spec/fixtures/api/schemas/registry/repositories.json b/spec/fixtures/api/schemas/registry/repositories.json new file mode 100644 index 00000000000..4978bd89cda --- /dev/null +++ b/spec/fixtures/api/schemas/registry/repositories.json @@ -0,0 +1,6 @@ +{ + "type": "array", + "items": { + "$ref": "repository.json" + } +} diff --git a/spec/fixtures/api/schemas/registry/repository.json b/spec/fixtures/api/schemas/registry/repository.json new file mode 100644 index 00000000000..4175642eb00 --- /dev/null +++ b/spec/fixtures/api/schemas/registry/repository.json @@ -0,0 +1,27 @@ +{ + "type": "object", + "required" : [ + "id", + "path", + "location", + "tags_path" + ], + "properties" : { + "id": { + "type": "integer" + }, + "path": { + "type": "string" + }, + "location": { + "type": "string" + }, + "tags_path": { + "type": "string" + }, + "destroy_path": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/registry/tag.json b/spec/fixtures/api/schemas/registry/tag.json new file mode 100644 index 00000000000..5bc307e0e64 --- /dev/null +++ b/spec/fixtures/api/schemas/registry/tag.json @@ -0,0 +1,28 @@ +{ + "type": "object", + "required" : [ + "name", + "location" + ], + "properties" : { + "name": { + "type": "string" + }, + "location": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "total_size": { + "type": "integer" + }, + "created_at": { + "type": "date" + }, + "destroy_path": { + "type": "string" + } + }, + "additionalProperties": false +} diff --git a/spec/fixtures/api/schemas/registry/tags.json b/spec/fixtures/api/schemas/registry/tags.json new file mode 100644 index 00000000000..c72f957459a --- /dev/null +++ b/spec/fixtures/api/schemas/registry/tags.json @@ -0,0 +1,6 @@ +{ + "type": "array", + "items": { + "$ref": "tag.json" + } +} diff --git a/spec/javascripts/notes/stores/helpers.js b/spec/javascripts/helpers/vuex_action_helper.js index 2d386fe1da5..2d386fe1da5 100644 --- a/spec/javascripts/notes/stores/helpers.js +++ b/spec/javascripts/helpers/vuex_action_helper.js diff --git a/spec/javascripts/notes/stores/actions_spec.js b/spec/javascripts/notes/stores/actions_spec.js index 2b2219dcf0c..3d1ca870ca4 100644 --- a/spec/javascripts/notes/stores/actions_spec.js +++ b/spec/javascripts/notes/stores/actions_spec.js @@ -1,5 +1,5 @@ import * as actions from '~/notes/stores/actions'; -import testAction from './helpers'; +import testAction from '../../helpers/vuex_action_helper'; import { discussionMock, notesDataMock, userDataMock, issueDataMock, individualNote } from '../mock_data'; describe('Actions Notes Store', () => { diff --git a/spec/javascripts/registry/components/app_spec.js b/spec/javascripts/registry/components/app_spec.js new file mode 100644 index 00000000000..43e7d9e1224 --- /dev/null +++ b/spec/javascripts/registry/components/app_spec.js @@ -0,0 +1,122 @@ +import Vue from 'vue'; +import registry from '~/registry/components/app.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; +import { reposServerResponse } from '../mock_data'; + +describe('Registry List', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(registry); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('with data', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify(reposServerResponse), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + vm = mountComponent(Component, { endpoint: 'foo' }); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should render a list of repos', (done) => { + setTimeout(() => { + expect(vm.$store.state.repos.length).toEqual(reposServerResponse.length); + + Vue.nextTick(() => { + expect( + vm.$el.querySelectorAll('.container-image').length, + ).toEqual(reposServerResponse.length); + done(); + }); + }, 0); + }); + + describe('delete repository', () => { + it('should be possible to delete a repo', (done) => { + setTimeout(() => { + Vue.nextTick(() => { + expect(vm.$el.querySelector('.container-image-head .js-remove-repo')).toBeDefined(); + done(); + }); + }, 0); + }); + }); + + describe('toggle repository', () => { + it('should open the container', (done) => { + setTimeout(() => { + Vue.nextTick(() => { + vm.$el.querySelector('.js-toggle-repo').click(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.js-toggle-repo i').className).toEqual('fa fa-chevron-up'); + done(); + }); + }); + }, 0); + }); + }); + }); + + describe('without data', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify([]), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + vm = mountComponent(Component, { endpoint: 'foo' }); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should render empty message', (done) => { + setTimeout(() => { + expect( + vm.$el.querySelector('p').textContent.trim(), + ).toEqual('No container images stored for this project. Add one by following the instructions above.'); + done(); + }, 0); + }); + }); + + describe('while loading data', () => { + const interceptor = (request, next) => { + next(request.respondWith(JSON.stringify(reposServerResponse), { + status: 200, + })); + }; + + beforeEach(() => { + Vue.http.interceptors.push(interceptor); + vm = mountComponent(Component, { endpoint: 'foo' }); + }); + + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + it('should render a loading spinner', (done) => { + Vue.nextTick(() => { + expect(vm.$el.querySelector('.fa-spinner')).not.toBe(null); + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/registry/components/collapsible_container_spec.js b/spec/javascripts/registry/components/collapsible_container_spec.js new file mode 100644 index 00000000000..5891921318a --- /dev/null +++ b/spec/javascripts/registry/components/collapsible_container_spec.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import collapsibleComponent from '~/registry/components/collapsible_container.vue'; +import store from '~/registry/stores'; +import { repoPropsData } from '../mock_data'; + +describe('collapsible registry container', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(collapsibleComponent); + vm = new Component({ + store, + propsData: { + repo: repoPropsData, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('toggle', () => { + it('should be closed by default', () => { + expect(vm.$el.querySelector('.container-image-tags')).toBe(null); + expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-right'); + }); + + it('should be open when user clicks on closed repo', (done) => { + vm.$el.querySelector('.js-toggle-repo').click(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.container-image-tags')).toBeDefined(); + expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-up'); + done(); + }); + }); + + it('should be closed when the user clicks on an opened repo', (done) => { + vm.$el.querySelector('.js-toggle-repo').click(); + + Vue.nextTick(() => { + vm.$el.querySelector('.js-toggle-repo').click(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.container-image-tags')).toBe(null); + expect(vm.$el.querySelector('.container-image-head i').className).toEqual('fa fa-chevron-right'); + done(); + }); + }); + }); + }); + + describe('delete repo', () => { + it('should be possible to delete a repo', () => { + expect(vm.$el.querySelector('.js-remove-repo')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/registry/components/table_registry_spec.js b/spec/javascripts/registry/components/table_registry_spec.js new file mode 100644 index 00000000000..6aa61afc445 --- /dev/null +++ b/spec/javascripts/registry/components/table_registry_spec.js @@ -0,0 +1,49 @@ +import Vue from 'vue'; +import tableRegistry from '~/registry/components/table_registry.vue'; +import store from '~/registry/stores'; +import { repoPropsData } from '../mock_data'; + +describe('table registry', () => { + let vm; + let Component; + + beforeEach(() => { + Component = Vue.extend(tableRegistry); + vm = new Component({ + store, + propsData: { + repo: repoPropsData, + }, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('should render a table with the registry list', () => { + expect( + vm.$el.querySelectorAll('table tbody tr').length, + ).toEqual(repoPropsData.list.length); + }); + + it('should render registry tag', () => { + const textRendered = vm.$el.querySelector('.table tbody tr').textContent.trim().replace(/\s\s+/g, ' '); + expect(textRendered).toContain(repoPropsData.list[0].tag); + expect(textRendered).toContain(repoPropsData.list[0].shortRevision); + expect(textRendered).toContain(repoPropsData.list[0].layers); + expect(textRendered).toContain(repoPropsData.list[0].size); + }); + + it('should be possible to delete a registry', () => { + expect( + vm.$el.querySelector('.table tbody tr .js-delete-registry'), + ).toBeDefined(); + }); + + describe('pagination', () => { + it('should be possible to change the page', () => { + expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); + }); + }); +}); diff --git a/spec/javascripts/registry/getters_spec.js b/spec/javascripts/registry/getters_spec.js new file mode 100644 index 00000000000..3d989541881 --- /dev/null +++ b/spec/javascripts/registry/getters_spec.js @@ -0,0 +1,43 @@ +import * as getters from '~/registry/stores/getters'; + +describe('Getters Registry Store', () => { + let state; + + beforeEach(() => { + state = { + isLoading: false, + endpoint: '/root/empty-project/container_registry.json', + repos: [{ + canDelete: true, + destroyPath: 'bar', + id: '134', + isLoading: false, + list: [], + location: 'foo', + name: 'gitlab-org/omnibus-gitlab/foo', + tagsPath: 'foo', + }, { + canDelete: true, + destroyPath: 'bar', + id: '123', + isLoading: false, + list: [], + location: 'foo', + name: 'gitlab-org/omnibus-gitlab', + tagsPath: 'foo', + }], + }; + }); + + describe('isLoading', () => { + it('should return the isLoading property', () => { + expect(getters.isLoading(state)).toEqual(state.isLoading); + }); + }); + + describe('repos', () => { + it('should return the repos', () => { + expect(getters.repos(state)).toEqual(state.repos); + }); + }); +}); diff --git a/spec/javascripts/registry/mock_data.js b/spec/javascripts/registry/mock_data.js new file mode 100644 index 00000000000..18600d00bff --- /dev/null +++ b/spec/javascripts/registry/mock_data.js @@ -0,0 +1,122 @@ +export const defaultState = { + isLoading: false, + endpoint: '', + repos: [], +}; + +export const reposServerResponse = [ + { + destroy_path: 'path', + id: '123', + location: 'location', + path: 'foo', + tags_path: 'tags_path', + }, + { + destroy_path: 'path_', + id: '456', + location: 'location_', + path: 'bar', + tags_path: 'tags_path_', + }, +]; + +export const registryServerResponse = [ + { + name: 'centos7', + short_revision: 'b118ab5b0', + revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', + size: 679, + layers: 19, + location: 'location', + created_at: 1505828744434, + destroy_path: 'path_', + }, + { + name: 'centos6', + short_revision: 'b118ab5b0', + revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', + size: 679, + layers: 19, + location: 'location', + created_at: 1505828744434, + }]; + +export const parsedReposServerResponse = [ + { + canDelete: true, + destroyPath: reposServerResponse[0].destroy_path, + id: reposServerResponse[0].id, + isLoading: false, + list: [], + location: reposServerResponse[0].location, + name: reposServerResponse[0].path, + tagsPath: reposServerResponse[0].tags_path, + }, + { + canDelete: true, + destroyPath: reposServerResponse[1].destroy_path, + id: reposServerResponse[1].id, + isLoading: false, + list: [], + location: reposServerResponse[1].location, + name: reposServerResponse[1].path, + tagsPath: reposServerResponse[1].tags_path, + }, +]; + +export const parsedRegistryServerResponse = [ + { + tag: registryServerResponse[0].name, + revision: registryServerResponse[0].revision, + shortRevision: registryServerResponse[0].short_revision, + size: registryServerResponse[0].size, + layers: registryServerResponse[0].layers, + location: registryServerResponse[0].location, + createdAt: registryServerResponse[0].created_at, + destroyPath: registryServerResponse[0].destroy_path, + canDelete: true, + }, + { + tag: registryServerResponse[1].name, + revision: registryServerResponse[1].revision, + shortRevision: registryServerResponse[1].short_revision, + size: registryServerResponse[1].size, + layers: registryServerResponse[1].layers, + location: registryServerResponse[1].location, + createdAt: registryServerResponse[1].created_at, + destroyPath: registryServerResponse[1].destroy_path, + canDelete: false, + }, +]; + +export const repoPropsData = { + canDelete: true, + destroyPath: 'path', + id: '123', + isLoading: false, + list: [ + { + tag: 'centos6', + revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43', + shortRevision: 'b118ab5b0', + size: 19, + layers: 10, + location: 'location', + createdAt: 1505828744434, + destroyPath: 'path', + canDelete: true, + }, + ], + location: 'location', + name: 'foo', + tagsPath: 'path', + pagination: { + perPage: 5, + page: 1, + total: 13, + totalPages: 1, + nextPage: null, + previousPage: null, + }, +}; diff --git a/spec/javascripts/registry/stores/actions_spec.js b/spec/javascripts/registry/stores/actions_spec.js new file mode 100644 index 00000000000..3c9da4f107b --- /dev/null +++ b/spec/javascripts/registry/stores/actions_spec.js @@ -0,0 +1,85 @@ +import Vue from 'vue'; +import VueResource from 'vue-resource'; +import _ from 'underscore'; +import * as actions from '~/registry/stores/actions'; +import * as types from '~/registry/stores/mutation_types'; +import testAction from '../../helpers/vuex_action_helper'; +import { + defaultState, + reposServerResponse, + registryServerResponse, + parsedReposServerResponse, +} from '../mock_data'; + +Vue.use(VueResource); + +describe('Actions Registry Store', () => { + let interceptor; + let mockedState; + + beforeEach(() => { + mockedState = defaultState; + }); + + describe('server requests', () => { + afterEach(() => { + Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); + }); + + describe('fetchRepos', () => { + beforeEach(() => { + interceptor = (request, next) => { + next(request.respondWith(JSON.stringify(reposServerResponse), { + status: 200, + })); + }; + + Vue.http.interceptors.push(interceptor); + }); + + it('should set receveived repos', (done) => { + testAction(actions.fetchRepos, null, mockedState, [ + { type: types.TOGGLE_MAIN_LOADING }, + { type: types.SET_REPOS_LIST, payload: reposServerResponse }, + ], done); + }); + }); + + describe('fetchList', () => { + beforeEach(() => { + interceptor = (request, next) => { + next(request.respondWith(JSON.stringify(registryServerResponse), { + status: 200, + })); + }; + + Vue.http.interceptors.push(interceptor); + }); + + it('should set received list', (done) => { + mockedState.repos = parsedReposServerResponse; + + testAction(actions.fetchList, { repo: mockedState.repos[1] }, mockedState, [ + { type: types.TOGGLE_REGISTRY_LIST_LOADING }, + { type: types.SET_REGISTRY_LIST, payload: registryServerResponse }, + ], done); + }); + }); + }); + + describe('setMainEndpoint', () => { + it('should commit set main endpoint', (done) => { + testAction(actions.setMainEndpoint, 'endpoint', mockedState, [ + { type: types.SET_MAIN_ENDPOINT, payload: 'endpoint' }, + ], done); + }); + }); + + describe('toggleLoading', () => { + it('should commit toggle main loading', (done) => { + testAction(actions.toggleLoading, null, mockedState, [ + { type: types.TOGGLE_MAIN_LOADING }, + ], done); + }); + }); +}); diff --git a/spec/javascripts/registry/stores/mutations_spec.js b/spec/javascripts/registry/stores/mutations_spec.js new file mode 100644 index 00000000000..2e4c0659daa --- /dev/null +++ b/spec/javascripts/registry/stores/mutations_spec.js @@ -0,0 +1,81 @@ +import mutations from '~/registry/stores/mutations'; +import * as types from '~/registry/stores/mutation_types'; +import { + defaultState, + reposServerResponse, + registryServerResponse, + parsedReposServerResponse, + parsedRegistryServerResponse, +} from '../mock_data'; + +describe('Mutations Registry Store', () => { + let mockState; + beforeEach(() => { + mockState = defaultState; + }); + + describe('SET_MAIN_ENDPOINT', () => { + it('should set the main endpoint', () => { + const expectedState = Object.assign({}, mockState, { endpoint: 'foo' }); + mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo'); + expect(mockState).toEqual(expectedState); + }); + }); + + describe('SET_REPOS_LIST', () => { + it('should set a parsed repository list', () => { + mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); + expect(mockState.repos).toEqual(parsedReposServerResponse); + }); + }); + + describe('TOGGLE_MAIN_LOADING', () => { + it('should set a parsed repository list', () => { + mutations[types.TOGGLE_MAIN_LOADING](mockState); + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_REGISTRY_LIST', () => { + it('should set a list of registries in a specific repository', () => { + mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); + mutations[types.SET_REGISTRY_LIST](mockState, { + repo: mockState.repos[0], + resp: registryServerResponse, + headers: { + 'x-per-page': 2, + 'x-page': 1, + 'x-total': 10, + }, + }); + + expect(mockState.repos[0].list).toEqual(parsedRegistryServerResponse); + expect(mockState.repos[0].pagination).toEqual({ + perPage: 2, + page: 1, + total: 10, + totalPages: NaN, + nextPage: NaN, + previousPage: NaN, + }); + }); + }); + + describe('TOGGLE_REGISTRY_LIST_LOADING', () => { + it('should toggle isLoading property for a specific repository', () => { + mutations[types.SET_REPOS_LIST](mockState, reposServerResponse); + mutations[types.SET_REGISTRY_LIST](mockState, { + repo: mockState.repos[0], + resp: registryServerResponse, + headers: { + 'x-per-page': 2, + 'x-page': 1, + 'x-total': 10, + }, + }); + + mutations[types.TOGGLE_REGISTRY_LIST_LOADING](mockState, mockState.repos[0]); + expect(mockState.repos[0].isLoading).toEqual(true); + }); + }); +}); diff --git a/spec/serializers/container_repository_entity_spec.rb b/spec/serializers/container_repository_entity_spec.rb new file mode 100644 index 00000000000..c589cd18f77 --- /dev/null +++ b/spec/serializers/container_repository_entity_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe ContainerRepositoryEntity do + let(:entity) do + described_class.new(repository, request: request) + end + + set(:project) { create(:project) } + set(:user) { create(:user) } + set(:repository) { create(:container_repository, project: project) } + + let(:request) { double('request') } + + subject { entity.as_json } + + before do + stub_container_registry_config(enabled: true) + allow(request).to receive(:project).and_return(project) + allow(request).to receive(:current_user).and_return(user) + end + + it 'exposes required informations' do + expect(subject).to include(:id, :path, :location, :tags_path) + end + + context 'when user can manage repositories' do + before do + project.add_developer(user) + end + + it 'exposes destroy_path' do + expect(subject).to include(:destroy_path) + end + end + + context 'when user cannot manage repositories' do + it 'does not expose destroy_path' do + expect(subject).not_to include(:destroy_path) + end + end +end diff --git a/spec/serializers/container_tag_entity_spec.rb b/spec/serializers/container_tag_entity_spec.rb new file mode 100644 index 00000000000..6dcc5204516 --- /dev/null +++ b/spec/serializers/container_tag_entity_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe ContainerTagEntity do + let(:entity) do + described_class.new(tag, request: request) + end + + set(:project) { create(:project) } + set(:user) { create(:user) } + set(:repository) { create(:container_repository, name: 'image', project: project) } + + let(:request) { double('request') } + let(:tag) { repository.tag('test') } + + subject { entity.as_json } + + before do + stub_container_registry_config(enabled: true) + stub_container_registry_tags(repository: /image/, tags: %w[test]) + allow(request).to receive(:project).and_return(project) + allow(request).to receive(:current_user).and_return(user) + end + + it 'exposes required informations' do + expect(subject).to include(:name, :location, :revision, :total_size, :created_at) + end + + context 'when user can manage repositories' do + before do + project.add_developer(user) + end + + it 'exposes destroy_path' do + expect(subject).to include(:destroy_path) + end + end + + context 'when user cannot manage repositories' do + it 'does not expose destroy_path' do + expect(subject).not_to include(:destroy_path) + end + end +end diff --git a/spec/support/stub_gitlab_calls.rb b/spec/support/stub_gitlab_calls.rb index 78a2ff73746..5f22d886910 100644 --- a/spec/support/stub_gitlab_calls.rb +++ b/spec/support/stub_gitlab_calls.rb @@ -39,11 +39,11 @@ module StubGitlabCalls .and_return({ 'tags' => tags }) allow_any_instance_of(ContainerRegistry::Client) - .to receive(:repository_manifest).with(repository) + .to receive(:repository_manifest).with(repository, anything) .and_return(stub_container_registry_tag_manifest) allow_any_instance_of(ContainerRegistry::Client) - .to receive(:blob).with(repository) + .to receive(:blob).with(repository, anything, 'application/octet-stream') .and_return(stub_container_registry_blob) end diff --git a/spec/views/projects/registry/repositories/index.html.haml_spec.rb b/spec/views/projects/registry/repositories/index.html.haml_spec.rb deleted file mode 100644 index cf0aa44a4a2..00000000000 --- a/spec/views/projects/registry/repositories/index.html.haml_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -require 'spec_helper' - -describe 'projects/registry/repositories/index' do - let(:group) { create(:group, path: 'group') } - let(:project) { create(:project, group: group, path: 'test') } - - let(:repository) do - create(:container_repository, project: project, name: 'image') - end - - before do - stub_container_registry_config(enabled: true, - host_port: 'registry.gitlab', - api_url: 'http://registry.gitlab') - - stub_container_registry_tags(repository: :any, tags: [:latest]) - - assign(:project, project) - assign(:images, [repository]) - - allow(view).to receive(:can?).and_return(true) - end - - it 'contains container repository path' do - render - - expect(rendered).to have_content 'group/test/image' - end - - it 'contains attribute for copying tag location into clipboard' do - render - - expect(rendered).to have_css 'button[data-clipboard-text="docker pull ' \ - 'registry.gitlab/group/test/image:latest"]' - end -end |