From 774380fb44c4732a2951900ea33e2d4551364836 Mon Sep 17 00:00:00 2001 From: Tim van den Eijnden Date: Mon, 14 Oct 2019 15:40:19 +0200 Subject: [PATCH 01/47] chore(icons): update fontawesome dependency (#3219) --- app/vendors.js | 6 +++--- package.json | 2 +- yarn.lock | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/vendors.js b/app/vendors.js index b1ff41f4e..bf1f680dc 100644 --- a/app/vendors.js +++ b/app/vendors.js @@ -1,8 +1,8 @@ import 'ui-select/dist/select.css'; import 'bootstrap/dist/css/bootstrap.css'; -import '@fortawesome/fontawesome-free-webfonts/css/fa-brands.css'; -import '@fortawesome/fontawesome-free-webfonts/css/fa-solid.css'; -import '@fortawesome/fontawesome-free-webfonts/css/fontawesome.css'; +import '@fortawesome/fontawesome-free/css/brands.css'; +import '@fortawesome/fontawesome-free/css/solid.css'; +import '@fortawesome/fontawesome-free/css/fontawesome.css'; import 'toastr/build/toastr.css'; import 'xterm/dist/xterm.css'; import 'angularjs-slider/dist/rzslider.css'; diff --git a/package.json b/package.json index c11dec3f2..e1cf4fdde 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ }, "dependencies": { "@babel/polyfill": "^7.2.5", - "@fortawesome/fontawesome-free-webfonts": "^1.0.9", + "@fortawesome/fontawesome-free": "^5.11.2", "@uirouter/angularjs": "~1.0.6", "angular": "~1.5.0", "angular-clipboard": "^1.6.2", diff --git a/yarn.lock b/yarn.lock index 8ef836d1b..55fb60149 100644 --- a/yarn.lock +++ b/yarn.lock @@ -600,10 +600,10 @@ lodash "^4.17.10" to-fast-properties "^2.0.0" -"@fortawesome/fontawesome-free-webfonts@^1.0.9": - version "1.0.9" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free-webfonts/-/fontawesome-free-webfonts-1.0.9.tgz#72f2c10453422aba0d338fa6a9cb761b50ba24d5" - integrity sha512-nLgl6b6a+tXaoJJnSRw0hjN8cWM/Q5DhxKAwI9Xr0AiC43lQ2F98vQ1KLA6kw5OoYeAyisGGqmlwtBj0WqOI5Q== +"@fortawesome/fontawesome-free@^5.11.2": + version "5.11.2" + resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz#8644bc25b19475779a7b7c1fc104bc0a794f4465" + integrity sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ== "@sindresorhus/is@^0.7.0": version "0.7.0" From e20a139c5a22fbf1c4b0742ee809f3f100f4780b Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 14 Oct 2019 15:43:27 +0200 Subject: [PATCH 02/47] fix(registry): remove checkboxes on repositories list (#3109) --- .../registryRepositoriesDatatable.html | 8 -------- 1 file changed, 8 deletions(-) diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html index 8f60ae379..8745cc2c9 100644 --- a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -15,10 +15,6 @@ - - - - Repository @@ -38,10 +34,6 @@ - - - - {{ item.Name }} From 8a8cef9b2006e085f4a1282205ddce8e1968232a Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 14 Oct 2019 15:43:58 +0200 Subject: [PATCH 03/47] feat(deps): multiselect library as dependency (#3255) --- app/__module.js | 1 - .../isteven-angular-multiselect/.npmignore | 8 - .../isteven-angular-multiselect/LICENSE.txt | 21 - .../isteven-angular-multiselect/README.md | 50 - .../isteven-multi-select.css | 299 ----- .../isteven-multi-select.js | 1127 ----------------- .../isteven-angular-multiselect/package.json | 22 - app/vendors.js | 2 + package.json | 1 + yarn.lock | 17 + 10 files changed, 20 insertions(+), 1528 deletions(-) delete mode 100644 app/libraries/isteven-angular-multiselect/.npmignore delete mode 100644 app/libraries/isteven-angular-multiselect/LICENSE.txt delete mode 100644 app/libraries/isteven-angular-multiselect/README.md delete mode 100644 app/libraries/isteven-angular-multiselect/isteven-multi-select.css delete mode 100644 app/libraries/isteven-angular-multiselect/isteven-multi-select.js delete mode 100644 app/libraries/isteven-angular-multiselect/package.json diff --git a/app/__module.js b/app/__module.js index 9eaa6248d..881453c9a 100644 --- a/app/__module.js +++ b/app/__module.js @@ -1,5 +1,4 @@ import '../assets/css/app.css'; -import './libraries/isteven-angular-multiselect/isteven-multi-select.css'; import angular from 'angular'; import './agent/_module'; diff --git a/app/libraries/isteven-angular-multiselect/.npmignore b/app/libraries/isteven-angular-multiselect/.npmignore deleted file mode 100644 index 84a23b94d..000000000 --- a/app/libraries/isteven-angular-multiselect/.npmignore +++ /dev/null @@ -1,8 +0,0 @@ -.git -.gitignore -bower.json -CHANGELOG.md -package.json -README.md -screenshot.png -/doc diff --git a/app/libraries/isteven-angular-multiselect/LICENSE.txt b/app/libraries/isteven-angular-multiselect/LICENSE.txt deleted file mode 100644 index 6e524fa92..000000000 --- a/app/libraries/isteven-angular-multiselect/LICENSE.txt +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/README.md b/app/libraries/isteven-angular-multiselect/README.md deleted file mode 100644 index 9c6255bcf..000000000 --- a/app/libraries/isteven-angular-multiselect/README.md +++ /dev/null @@ -1,50 +0,0 @@ -# AngularJS MultiSelect -Pure AngularJS directive which creates a dropdown button with multiple or single selections. -Doesn't require jQuery and works well with other Javascript libraries. - -![Screenshot](https://raw.githubusercontent.com/isteven/angular-multi-select/master/screenshot.png) - -### Demo & How To -Go to http://isteven.github.io/angular-multi-select - -### Current Version -4.0.0 - -### Change Log -See CHANGELOG.md. -For those who's upgrading from version 2.x.x, do note that this version is not backward-compatible. Please read the manual -thoroughly and update your code accordingly. - -### Bug Reporting -Please follow these steps: - -1. **READ THE MANUAL AGAIN**. You might have missed something. This includes the MINIMUM ANGULARJS VERSION and the SUPPORTED BROWSERS. -2. The next step is to search in Github's issue section first. There might already be an answer for similar issue. Do check both open and closed issues. -3. If there's no previous issue found, then please create a new issue in https://github.com/isteven/angular-multi-select/issues. -4. Please **replicate the problem in JSFiddle or Plunker** (or any other online JS collaboration tool), and include the URL in the issue you are creating. -5. When you're done, please close the issue you've created. - -### Licence -Released under the MIT license: - -The MIT License (MIT) - -Copyright (c) 2014-2015 Ignatius Steven (https://github.com/isteven) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.css b/app/libraries/isteven-angular-multiselect/isteven-multi-select.css deleted file mode 100644 index 44dfc95f5..000000000 --- a/app/libraries/isteven-angular-multiselect/isteven-multi-select.css +++ /dev/null @@ -1,299 +0,0 @@ -/* - * Don't modify things marked with ! - unless you know what you're doing - */ - -/* ! vertical layout */ -.multiSelect .vertical { - float: none; -} - -/* ! horizontal layout */ -.multiSelect .horizontal:not(.multiSelectGroup) { - float: left; -} - -/* ! create a "row" */ -.multiSelect .line { - padding: 2px 0px 4px 0px; - max-height: 30px; - overflow: hidden; - box-sizing: content-box; -} - -/* ! create a "column" */ -.multiSelect .acol { - display: inline-block; - min-width: 12px; -} - -/* ! */ -.multiSelect .inlineBlock { - display: inline-block; -} - -/* the multiselect button */ -.multiSelect > button { - display: inline-block; - position: relative; - text-align: center; - cursor: pointer; - border: 1px solid #c6c6c6; - padding: 1px 8px 1px 8px; - font-size: 14px; - min-height : 38px !important; - border-radius: 4px; - color: #555; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -o-user-select: none; - user-select: none; - white-space:normal; - background-color: #fff; - background-image: linear-gradient(#fff, #f7f7f7); -} - -/* button: hover */ -.multiSelect > button:hover { - background-image: linear-gradient(#fff, #e9e9e9); -} - -/* button: disabled */ -.multiSelect > button:disabled { - background-image: linear-gradient(#fff, #fff); - border: 1px solid #ddd; - color: #999; -} - -/* button: clicked */ -.multiSelect .buttonClicked { - box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15) inset, 0 1px 2px rgba(0, 0, 0, 0.05); -} - -/* labels on the button */ -.multiSelect .buttonLabel { - display: inline-block; - padding: 5px 0px 5px 0px; -} - -/* downward pointing arrow */ -.multiSelect .caret { - display: inline-block; - width: 0; - height: 0; - margin: 0px 0px 1px 12px !important; - vertical-align: middle; - border-top: 4px solid #333; - border-right: 4px solid transparent; - border-left: 4px solid transparent; - border-bottom: 0 dotted; -} - -/* the main checkboxes and helper layer */ -.multiSelect .checkboxLayer { - background-color: #fff; - position: absolute; - z-index: 999; - border: 1px solid rgba(0, 0, 0, 0.15); - border-radius: 4px; - -webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); - box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175); - min-width:278px; - display: none !important; -} - -/* container of helper elements */ -.multiSelect .helperContainer { - border-bottom: 1px solid #ddd; - padding: 8px 8px 0px 8px; -} - -/* helper buttons (select all, none, reset); */ -.multiSelect .helperButton { - display: inline; - text-align: center; - cursor: pointer; - border: 1px solid #ccc; - height: 26px; - font-size: 13px; - border-radius: 2px; - color: #666; - background-color: #f1f1f1; - line-height: 1.6; - margin: 0px 0px 8px 0px; -} - -.multiSelect .helperButton.reset{ - float: right; -} - -.multiSelect .helperButton:not( .reset ) { - margin-right: 4px; -} - -/* clear button */ -.multiSelect .clearButton { - position: absolute; - display: inline; - text-align: center; - cursor: pointer; - border: 1px solid #ccc; - height: 22px; - width: 22px; - font-size: 13px; - border-radius: 2px; - color: #666; - background-color: #f1f1f1; - line-height: 1.4; - right : 2px; - top: 4px; -} - -/* filter */ -.multiSelect .inputFilter { - border-radius: 2px; - border: 1px solid #ccc; - height: 26px; - font-size: 14px; - width:100%; - padding-left:7px; - -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ - -moz-box-sizing: border-box; /* Firefox, other Gecko */ - box-sizing: border-box; /* Opera/IE 8+ */ - color: #888; - margin: 0px 0px 8px 0px; - -webkit-box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); - box-shadow: inset 0 1px 1px rgba(0, 0, 0, .075); -} - -/* helper elements on hover & focus */ -.multiSelect .clearButton:hover, -.multiSelect .helperButton:hover { - border: 1px solid #ccc; - color: #999; - background-color: #f4f4f4; -} -.multiSelect .helperButton:disabled { - color: #ccc; - border: 1px solid #ddd; -} - -.multiSelect .clearButton:focus, -.multiSelect .helperButton:focus, -.multiSelect .inputFilter:focus { - border: 1px solid #66AFE9 !important; - outline: 0; - -webkit-box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; - box-shadow: inset 0 0 1px rgba(0,0,0,.065), 0 0 5px rgba(102, 175, 233, .6) !important; -} - -/* container of multi select items */ -.multiSelect .checkBoxContainer { - display: block; - padding: 8px; - overflow: hidden; -} - -/* ! to show / hide the checkbox layer above */ -.multiSelect .show { - display: block !important; -} - -/* item labels */ -.multiSelect .multiSelectItem { - display: block; - padding: 3px; - color: #444; - white-space: nowrap; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - -o-user-select: none; - user-select: none; - border: 1px solid transparent; - position: relative; - min-width:278px; - min-height: 32px; -} - -/* Styling on selected items */ -.multiSelect .multiSelectItem:not(.multiSelectGroup).selected -{ - background-image: linear-gradient( #e9e9e9, #f1f1f1 ); - color: #555; - cursor: pointer; - border-top: 1px solid #e4e4e4; - border-left: 1px solid #e4e4e4; - border-right: 1px solid #d9d9d9; -} - -.multiSelect .multiSelectItem .acol label { - display: inline-block; - padding-right: 30px; - margin: 0px; - font-weight: normal; - line-height: normal; -} - -/* item labels focus on mouse hover */ -.multiSelect .multiSelectItem:hover, -.multiSelect .multiSelectGroup:hover { - background-image: linear-gradient( #c1c1c1, #999 ) !important; - color: #fff !important; - cursor: pointer; - border: 1px solid #ccc !important; -} - -/* item labels focus using keyboard */ -.multiSelect .multiSelectFocus { - background-image: linear-gradient( #c1c1c1, #999 ) !important; - color: #fff !important; - cursor: pointer; - border: 1px solid #ccc !important; -} - -/* change mouse pointer into the pointing finger */ -.multiSelect .multiSelectItem span:hover, -.multiSelect .multiSelectGroup span:hover -{ - cursor: pointer; -} - -/* ! group labels */ -.multiSelect .multiSelectGroup { - display: block; - clear: both; -} - -/* right-align the tick mark (✔) */ -.multiSelect .tickMark { - display:inline-block; - position: absolute; - right: 10px; - top: 7px; - font-size: 10px; -} - -/* hide the original HTML checkbox away */ -.multiSelect .checkbox { - color: #ddd !important; - position: absolute; - left: -9999px; - cursor: pointer; -} - -/* checkboxes currently disabled */ -.multiSelect .disabled, -.multiSelect .disabled:hover, -.multiSelect .disabled label input:hover ~ span { - color: #c4c4c4 !important; - cursor: not-allowed !important; -} - -/* If you use images in button / checkbox label, you might want to change the image style here. */ -.multiSelect img { - vertical-align: middle; - margin-bottom:0px; - max-height: 22px; - max-width:22px; -} diff --git a/app/libraries/isteven-angular-multiselect/isteven-multi-select.js b/app/libraries/isteven-angular-multiselect/isteven-multi-select.js deleted file mode 100644 index 02b136aa3..000000000 --- a/app/libraries/isteven-angular-multiselect/isteven-multi-select.js +++ /dev/null @@ -1,1127 +0,0 @@ -/* - * Angular JS Multi Select - * Creates a dropdown-like button with checkboxes. - * - * Project started on: Tue, 14 Jan 2014 - 5:18:02 PM - * Current version: 4.0.0 - * - * Released under the MIT License - * -------------------------------------------------------------------------------- - * The MIT License (MIT) - * - * Copyright (c) 2014 Ignatius Steven (https://github.com/isteven) - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - * -------------------------------------------------------------------------------- - */ - -'use strict' - -angular.module( 'isteven-multi-select', ['ng'] ).directive( 'istevenMultiSelect' , [ '$sce', '$timeout', '$sanitize', function ( $sce, $timeout, $sanitize) { - return { - restrict: - 'AE', - - scope: - { - // models - inputModel : '=', - outputModel : '=', - - // settings based on attribute - isDisabled : '=', - - // callbacks - onClear : '&', - onClose : '&', - onSearchChange : '&', - onItemClick : '&', - onOpen : '&', - onReset : '&', - onSelectAll : '&', - onSelectNone : '&', - - // i18n - translation : '=' - }, - - /* - * The rest are attributes. They don't need to be parsed / binded, so we can safely access them by value. - * - buttonLabel, directiveId, helperElements, itemLabel, maxLabels, orientation, selectionMode, minSearchLength, - * tickProperty, disableProperty, groupProperty, searchProperty, maxHeight, outputProperties - */ - - templateUrl: - 'isteven-multi-select.htm', - - link: function ( $scope, element, attrs ) { - - $scope.backUp = []; - $scope.varButtonLabel = ''; - $scope.spacingProperty = ''; - $scope.indexProperty = ''; - $scope.orientationH = false; - $scope.orientationV = true; - $scope.filteredModel = []; - $scope.inputLabel = { labelFilter: '' }; - $scope.tabIndex = 0; - $scope.lang = {}; - $scope.helperStatus = { - all : true, - none : true, - reset : true, - filter : true - }; - - var - prevTabIndex = 0, - helperItems = [], - helperItemsLength = 0, - checkBoxLayer = '', - // scrolled = false, - // selectedItems = [], - formElements = [], - vMinSearchLength = 0, - clickedItem = null - - // v3.0.0 - // clear button clicked - $scope.clearClicked = function( e ) { - $scope.inputLabel.labelFilter = ''; - $scope.updateFilter(); - $scope.select( 'clear', e ); - } - - // A little hack so that AngularJS ng-repeat can loop using start and end index like a normal loop - // http://stackoverflow.com/questions/16824853/way-to-ng-repeat-defined-number-of-times-instead-of-repeating-over-array - $scope.numberToArray = function( num ) { - return new Array( num ); - } - - // Call this function when user type on the filter field - $scope.searchChanged = function() { - if ( $scope.inputLabel.labelFilter.length < vMinSearchLength && $scope.inputLabel.labelFilter.length > 0 ) { - return false; - } - $scope.updateFilter(); - } - - $scope.updateFilter = function() - { - // we check by looping from end of input-model - $scope.filteredModel = []; - var i = 0; - - if ( typeof $scope.inputModel === 'undefined' ) { - return false; - } - - for( i = $scope.inputModel.length - 1; i >= 0; i-- ) { - - // if it's group end, we push it to filteredModel[]; - if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === false ) { - $scope.filteredModel.push( $scope.inputModel[ i ] ); - } - - // if it's data - var gotData = false; - if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] === 'undefined' ) { - - // If we set the search-key attribute, we use this loop. - if ( typeof attrs.searchProperty !== 'undefined' && attrs.searchProperty !== '' ) { - - for (const key in $scope.inputModel[ i ] ) { - if ( - typeof $scope.inputModel[ i ][ key ] !== 'boolean' - && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 - && attrs.searchProperty.indexOf( key ) > -1 - ) { - gotData = true; - break; - } - } - } - // if there's no search-key attribute, we use this one. Much better on performance. - else { - for ( const key in $scope.inputModel[ i ] ) { - if ( - typeof $scope.inputModel[ i ][ key ] !== 'boolean' - && String( $scope.inputModel[ i ][ key ] ).toUpperCase().indexOf( $scope.inputLabel.labelFilter.toUpperCase() ) >= 0 - ) { - gotData = true; - break; - } - } - } - - if ( gotData === true ) { - // push - $scope.filteredModel.push( $scope.inputModel[ i ] ); - } - } - - // if it's group start - if ( typeof $scope.inputModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.inputModel[ i ][ attrs.groupProperty ] === true ) { - - if ( typeof $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] !== 'undefined' - && $scope.filteredModel[ $scope.filteredModel.length - 1 ][ attrs.groupProperty ] === false ) { - $scope.filteredModel.pop(); - } - else { - $scope.filteredModel.push( $scope.inputModel[ i ] ); - } - } - } - - $scope.filteredModel.reverse(); - - $timeout( function() { - - $scope.getFormElements(); - - // Callback: on filter change - if ( $scope.inputLabel.labelFilter.length > vMinSearchLength ) { - - var filterObj = []; - - angular.forEach( $scope.filteredModel, function( value ) { - if ( typeof value !== 'undefined' ) { - if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { - var tempObj = angular.copy( value ); - var index = filterObj.push( tempObj ); - delete filterObj[ index - 1 ][ $scope.indexProperty ]; - delete filterObj[ index - 1 ][ $scope.spacingProperty ]; - } - } - }); - - $scope.onSearchChange({ - data: - { - keyword: $scope.inputLabel.labelFilter, - result: filterObj - } - }); - } - },0); - }; - - // List all the input elements. We need this for our keyboard navigation. - // This function will be called everytime the filter is updated. - // Depending on the size of filtered mode, might not good for performance, but oh well.. - $scope.getFormElements = function() { - formElements = []; - - var - selectButtons = [], - inputField = [], - checkboxes = [], - clearButton = []; - - // If available, then get select all, select none, and reset buttons - if ( $scope.helperStatus.all || $scope.helperStatus.none || $scope.helperStatus.reset ) { - selectButtons = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); - // If available, then get the search box and the clear button - if ( $scope.helperStatus.filter ) { - // Get helper - search and clear button. - inputField = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'input' ); - clearButton = element.children().children().next().children().children().next()[ 0 ].getElementsByTagName( 'button' ); - } - } - else { - if ( $scope.helperStatus.filter ) { - // Get helper - search and clear button. - inputField = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'input' ); - clearButton = element.children().children().next().children().children()[ 0 ].getElementsByTagName( 'button' ); - } - } - - // Get checkboxes - if ( !$scope.helperStatus.all && !$scope.helperStatus.none && !$scope.helperStatus.reset && !$scope.helperStatus.filter ) { - checkboxes = element.children().children().next()[ 0 ].getElementsByTagName( 'input' ); - } - else { - checkboxes = element.children().children().next().children().next()[ 0 ].getElementsByTagName( 'input' ); - } - - // Push them into global array formElements[] - for ( let i = 0; i < selectButtons.length ; i++ ) { formElements.push( selectButtons[ i ] ); } - for ( let i = 0; i < inputField.length ; i++ ) { formElements.push( inputField[ i ] ); } - for ( let i = 0; i < clearButton.length ; i++ ) { formElements.push( clearButton[ i ] ); } - for ( let i = 0; i < checkboxes.length ; i++ ) { formElements.push( checkboxes[ i ] ); } - } - - // check if an item has attrs.groupProperty (be it true or false) - $scope.isGroupMarker = function( item , type ) { - if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === type ) return true; - return false; - } - - $scope.removeGroupEndMarker = function( item ) { - if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) return false; - return true; - } - - // call this function when an item is clicked - $scope.syncItems = function( item, e, ng_repeat_index ) { - - e.preventDefault(); - e.stopPropagation(); - - // if the directive is globaly disabled, do nothing - if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { - return false; - } - - // if item is disabled, do nothing - if ( typeof attrs.isDisabled !== 'undefined' && $scope.isDisabled === true ) { - return false; - } - - // if end group marker is clicked, do nothing - if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === false ) { - return false; - } - - var index = $scope.filteredModel.indexOf( item ); - - // if the start of group marker is clicked ( only for multiple selection! ) - // how it works: - // - if, in a group, there are items which are not selected, then they all will be selected - // - if, in a group, all items are selected, then they all will be de-selected - if ( typeof item[ attrs.groupProperty ] !== 'undefined' && item[ attrs.groupProperty ] === true ) { - - // this is only for multiple selection, so if selection mode is single, do nothing - if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { - return false; - } - - var i, j; - var startIndex = 0; - var endIndex = $scope.filteredModel.length - 1; - var tempArr = []; - - // nest level is to mark the depth of the group. - // when you get into a group (start group marker), nestLevel++ - // when you exit a group (end group marker), nextLevel-- - var nestLevel = 0; - - // we loop throughout the filtered model (not whole model) - for( i = index ; i < $scope.filteredModel.length ; i++) { - - // this break will be executed when we're done processing each group - if ( nestLevel === 0 && i > index ) - { - break; - } - - if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === true ) { - - // To cater multi level grouping - if ( tempArr.length === 0 ) { - startIndex = i + 1; - } - nestLevel = nestLevel + 1; - } - - // if group end - else if ( typeof $scope.filteredModel[ i ][ attrs.groupProperty ] !== 'undefined' && $scope.filteredModel[ i ][ attrs.groupProperty ] === false ) { - - nestLevel = nestLevel - 1; - - // cek if all are ticked or not - if ( tempArr.length > 0 && nestLevel === 0 ) { - - var allTicked = true; - - endIndex = i; - - for ( j = 0; j < tempArr.length ; j++ ) { - if ( typeof tempArr[ j ][ $scope.tickProperty ] !== 'undefined' && tempArr[ j ][ $scope.tickProperty ] === false ) { - allTicked = false; - break; - } - } - - if ( allTicked === true ) { - for ( j = startIndex; j <= endIndex ; j++ ) { - if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { - if ( typeof attrs.disableProperty === 'undefined' ) { - $scope.filteredModel[ j ][ $scope.tickProperty ] = false; - // we refresh input model as well - inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; - $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; - } - else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { - $scope.filteredModel[ j ][ $scope.tickProperty ] = false; - // we refresh input model as well - inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; - $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = false; - } - } - } - } - - else { - for ( j = startIndex; j <= endIndex ; j++ ) { - if ( typeof $scope.filteredModel[ j ][ attrs.groupProperty ] === 'undefined' ) { - if ( typeof attrs.disableProperty === 'undefined' ) { - $scope.filteredModel[ j ][ $scope.tickProperty ] = true; - // we refresh input model as well - inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; - $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; - - } - else if ( $scope.filteredModel[ j ][ attrs.disableProperty ] !== true ) { - $scope.filteredModel[ j ][ $scope.tickProperty ] = true; - // we refresh input model as well - inputModelIndex = $scope.filteredModel[ j ][ $scope.indexProperty ]; - $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = true; - } - } - } - } - } - } - - // if data - else { - tempArr.push( $scope.filteredModel[ i ] ); - } - } - } - - // if an item (not group marker) is clicked - else { - - // If it's single selection mode - if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { - - // first, set everything to false - for( i=0 ; i < $scope.filteredModel.length ; i++) { - $scope.filteredModel[ i ][ $scope.tickProperty ] = false; - } - for( i=0 ; i < $scope.inputModel.length ; i++) { - $scope.inputModel[ i ][ $scope.tickProperty ] = false; - } - - // then set the clicked item to true - $scope.filteredModel[ index ][ $scope.tickProperty ] = true; - } - - // Multiple - else { - $scope.filteredModel[ index ][ $scope.tickProperty ] = !$scope.filteredModel[ index ][ $scope.tickProperty ]; - } - - // we refresh input model as well - var inputModelIndex = $scope.filteredModel[ index ][ $scope.indexProperty ]; - $scope.inputModel[ inputModelIndex ][ $scope.tickProperty ] = $scope.filteredModel[ index ][ $scope.tickProperty ]; - } - - // we execute the callback function here - clickedItem = angular.copy( item ); - if ( clickedItem !== null ) { - $timeout( function() { - delete clickedItem[ $scope.indexProperty ]; - delete clickedItem[ $scope.spacingProperty ]; - $scope.onItemClick( { data: clickedItem } ); - clickedItem = null; - }, 0 ); - } - - $scope.refreshOutputModel(); - $scope.refreshButton(); - - // We update the index here - prevTabIndex = $scope.tabIndex; - $scope.tabIndex = ng_repeat_index + helperItemsLength; - - // Set focus on the hidden checkbox - e.target.focus(); - - // set & remove CSS style - $scope.removeFocusStyle( prevTabIndex ); - $scope.setFocusStyle( $scope.tabIndex ); - - if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { - // on single selection mode, we then hide the checkbox layer - $scope.toggleCheckboxes( e ); - } - } - - // update $scope.outputModel - $scope.refreshOutputModel = function() { - - $scope.outputModel = []; - var - outputProps = [], - tempObj = {}; - - // v4.0.0 - if ( typeof attrs.outputProperties !== 'undefined' ) { - outputProps = attrs.outputProperties.split(' '); - angular.forEach( $scope.inputModel, function( value ) { - if ( - typeof value !== 'undefined' - && typeof value[ attrs.groupProperty ] === 'undefined' - && value[ $scope.tickProperty ] === true - ) { - tempObj = {}; - angular.forEach( value, function( value1, key1 ) { - if ( outputProps.indexOf( key1 ) > -1 ) { - tempObj[ key1 ] = value1; - } - }); - var index = $scope.outputModel.push( tempObj ); - delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; - delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; - } - }); - } - else { - angular.forEach( $scope.inputModel, function( value ) { - if ( - typeof value !== 'undefined' - && typeof value[ attrs.groupProperty ] === 'undefined' - && value[ $scope.tickProperty ] === true - ) { - var temp = angular.copy( value ); - var index = $scope.outputModel.push( temp ); - delete $scope.outputModel[ index - 1 ][ $scope.indexProperty ]; - delete $scope.outputModel[ index - 1 ][ $scope.spacingProperty ]; - } - }); - } - } - - // refresh button label - $scope.refreshButton = function() { - - $scope.varButtonLabel = ''; - var ctr = 0; - - // refresh button label... - if ( $scope.outputModel.length === 0 ) { - // https://github.com/isteven/angular-multi-select/pull/19 - $scope.varButtonLabel = $scope.lang.nothingSelected; - } - else { - var tempMaxLabels = $scope.outputModel.length; - if ( typeof attrs.maxLabels !== 'undefined' && attrs.maxLabels !== '' ) { - tempMaxLabels = attrs.maxLabels; - } - - // if max amount of labels displayed.. - if ( $scope.outputModel.length > tempMaxLabels ) { - $scope.more = true; - } - else { - $scope.more = false; - } - - angular.forEach( $scope.inputModel, function( value ) { - if ( typeof value !== 'undefined' && value[ attrs.tickProperty ] === true ) { - if ( ctr < tempMaxLabels ) { - $scope.varButtonLabel += ( $scope.varButtonLabel.length > 0 ? ',
' : '
') + $scope.writeLabel( value, 'buttonLabel' ); - } - ctr++; - } - }); - - if ( $scope.more === true ) { - // https://github.com/isteven/angular-multi-select/pull/16 - if (tempMaxLabels > 0) { - $scope.varButtonLabel += ', ... '; - } - $scope.varButtonLabel += '(' + $scope.outputModel.length + ')'; - } - } - // $scope.varButtonLabel = $sce.trustAsHtml( $scope.varButtonLabel + '' ); - $scope.varButtonLabel = $sanitize($scope.varButtonLabel + ''); - } - - // Check if a checkbox is disabled or enabled. It will check the granular control (disableProperty) and global control (isDisabled) - // Take note that the granular control has higher priority. - $scope.itemIsDisabled = function( item ) { - - if ( typeof attrs.disableProperty !== 'undefined' && item[ attrs.disableProperty ] === true ) { - return true; - } - else { - if ( $scope.isDisabled === true ) { - return true; - } - else { - return false; - } - } - - } - - // A simple function to parse the item label settings. Used on the buttons and checkbox labels. - $scope.writeLabel = function( item, type ) { - // type is either 'itemLabel' or 'buttonLabel' - var temp = attrs[ type ].split( ' ' ); - var label = ''; - - angular.forEach( temp, function( value ) { - item[ value ] && ( label += ' ' + value.split( '.' ).reduce( function( prev, current ) { - return prev[ current ]; - }, item )); - }); - - if ( type.toUpperCase() === 'BUTTONLABEL' ) { - return label; - } - // return $sce.trustAsHtml( label ); - return $sanitize(label); - } - - // UI operations to show/hide checkboxes based on click event.. - $scope.toggleCheckboxes = function( ) { - - // We grab the button - var clickedEl = element.children()[0]; - - // Just to make sure.. had a bug where key events were recorded twice - angular.element( document ).off( 'click', $scope.externalClickListener ); - angular.element( document ).off( 'keydown', $scope.keyboardListener ); - - // The idea below was taken from another multi-select directive - https://github.com/amitava82/angular-multiselect - // His version is awesome if you need a more simple multi-select approach. - - // close - if ( angular.element( checkBoxLayer ).hasClass( 'show' )) { - - angular.element( checkBoxLayer ).removeClass( 'show' ); - angular.element( clickedEl ).removeClass( 'buttonClicked' ); - angular.element( document ).off( 'click', $scope.externalClickListener ); - angular.element( document ).off( 'keydown', $scope.keyboardListener ); - - // clear the focused element; - $scope.removeFocusStyle( $scope.tabIndex ); - if ( typeof formElements[ $scope.tabIndex ] !== 'undefined' ) { - formElements[ $scope.tabIndex ].blur(); - } - - // close callback - $timeout( function() { - $scope.onClose(); - }, 0 ); - - // set focus on button again - element.children().children()[ 0 ].focus(); - } - // open - else - { - // clear filter - $scope.inputLabel.labelFilter = ''; - $scope.updateFilter(); - - helperItems = []; - helperItemsLength = 0; - - angular.element( checkBoxLayer ).addClass( 'show' ); - angular.element( clickedEl ).addClass( 'buttonClicked' ); - - // Attach change event listener on the input filter. - // We need this because ng-change is apparently not an event listener. - angular.element( document ).on( 'click', $scope.externalClickListener ); - angular.element( document ).on( 'keydown', $scope.keyboardListener ); - - // to get the initial tab index, depending on how many helper elements we have. - // priority is to always focus it on the input filter - $scope.getFormElements(); - $scope.tabIndex = 0; - - var helperContainer = angular.element( element[ 0 ].querySelector( '.helperContainer' ) )[0]; - - if ( typeof helperContainer !== 'undefined' ) { - for ( var i = 0; i < helperContainer.getElementsByTagName( 'BUTTON' ).length ; i++ ) { - helperItems[ i ] = helperContainer.getElementsByTagName( 'BUTTON' )[ i ]; - } - helperItemsLength = helperItems.length + helperContainer.getElementsByTagName( 'INPUT' ).length; - } - - // focus on the filter element on open. - if ( element[ 0 ].querySelector( '.inputFilter' ) ) { - element[ 0 ].querySelector( '.inputFilter' ).focus(); - $scope.tabIndex = $scope.tabIndex + helperItemsLength - 2; - // blur button in vain - angular.element( element ).children()[ 0 ].blur(); - } - // if there's no filter then just focus on the first checkbox item - else { - if ( !$scope.isDisabled ) { - $scope.tabIndex = $scope.tabIndex + helperItemsLength; - if ( $scope.inputModel.length > 0 ) { - formElements[ $scope.tabIndex ].focus(); - $scope.setFocusStyle( $scope.tabIndex ); - // blur button in vain - angular.element( element ).children()[ 0 ].blur(); - } - } - } - - // open callback - $scope.onOpen(); - } - } - - // handle clicks outside the button / multi select layer - $scope.externalClickListener = function( e ) { - - var targetsArr = element.find( e.target.tagName ); - for (var i = 0; i < targetsArr.length; i++) { - if ( e.target == targetsArr[i] ) { - return; - } - } - - angular.element( checkBoxLayer.previousSibling ).removeClass( 'buttonClicked' ); - angular.element( checkBoxLayer ).removeClass( 'show' ); - angular.element( document ).off( 'click', $scope.externalClickListener ); - angular.element( document ).off( 'keydown', $scope.keyboardListener ); - - // close callback - $timeout( function() { - $scope.onClose(); - }, 0 ); - - // set focus on button again - element.children().children()[ 0 ].focus(); - } - - // select All / select None / reset buttons - $scope.select = function( type, e ) { - - var helperIndex = helperItems.indexOf( e.target ); - $scope.tabIndex = helperIndex; - - switch( type.toUpperCase() ) { - case 'ALL': - angular.forEach( $scope.filteredModel, function( value ) { - if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { - if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { - value[ $scope.tickProperty ] = true; - } - } - }); - $scope.refreshOutputModel(); - $scope.refreshButton(); - $scope.onSelectAll(); - break; - case 'NONE': - angular.forEach( $scope.filteredModel, function( value ) { - if ( typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { - if ( typeof value[ attrs.groupProperty ] === 'undefined' ) { - value[ $scope.tickProperty ] = false; - } - } - }); - $scope.refreshOutputModel(); - $scope.refreshButton(); - $scope.onSelectNone(); - break; - case 'RESET': - angular.forEach( $scope.filteredModel, function( value ) { - if ( typeof value[ attrs.groupProperty ] === 'undefined' && typeof value !== 'undefined' && value[ attrs.disableProperty ] !== true ) { - var temp = value[ $scope.indexProperty ]; - value[ $scope.tickProperty ] = $scope.backUp[ temp ][ $scope.tickProperty ]; - } - }); - $scope.refreshOutputModel(); - $scope.refreshButton(); - $scope.onReset(); - break; - case 'CLEAR': - $scope.tabIndex = $scope.tabIndex + 1; - $scope.onClear(); - break; - case 'FILTER': - $scope.tabIndex = helperItems.length - 1; - break; - default: - } - } - - // just to create a random variable name - function genRandomString( length ) { - var possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; - var temp = ''; - for( var i=0; i < length; i++ ) { - temp += possible.charAt( Math.floor( Math.random() * possible.length )); - } - return temp; - } - - // count leading spaces - $scope.prepareGrouping = function() { - var spacing = 0; - angular.forEach( $scope.filteredModel, function( value ) { - value[ $scope.spacingProperty ] = spacing; - if ( value[ attrs.groupProperty ] === true ) { - spacing+=2; - } - else if ( value[ attrs.groupProperty ] === false ) { - spacing-=2; - } - }); - } - - // prepare original index - $scope.prepareIndex = function() { - var ctr = 0; - angular.forEach( $scope.filteredModel, function( value ) { - value[ $scope.indexProperty ] = ctr; - ctr++; - }); - } - - // navigate using up and down arrow - $scope.keyboardListener = function( e ) { - - var key = e.keyCode ? e.keyCode : e.which; - var isNavigationKey = false; - - // ESC key (close) - if ( key === 27 ) { - e.preventDefault(); - e.stopPropagation(); - $scope.toggleCheckboxes( e ); - } - - - // next element ( tab, down & right key ) - else if ( key === 40 || key === 39 || ( !e.shiftKey && key == 9 ) ) { - - isNavigationKey = true; - prevTabIndex = $scope.tabIndex; - $scope.tabIndex++; - if ( $scope.tabIndex > formElements.length - 1 ) { - $scope.tabIndex = 0; - prevTabIndex = formElements.length - 1; - } - while ( formElements[ $scope.tabIndex ].disabled === true ) { - $scope.tabIndex++; - if ( $scope.tabIndex > formElements.length - 1 ) { - $scope.tabIndex = 0; - } - if ( $scope.tabIndex === prevTabIndex ) { - break; - } - } - } - - // prev element ( shift+tab, up & left key ) - else if ( key === 38 || key === 37 || ( e.shiftKey && key == 9 ) ) { - isNavigationKey = true; - prevTabIndex = $scope.tabIndex; - $scope.tabIndex--; - if ( $scope.tabIndex < 0 ) { - $scope.tabIndex = formElements.length - 1; - prevTabIndex = 0; - } - while ( formElements[ $scope.tabIndex ].disabled === true ) { - $scope.tabIndex--; - if ( $scope.tabIndex === prevTabIndex ) { - break; - } - if ( $scope.tabIndex < 0 ) { - $scope.tabIndex = formElements.length - 1; - } - } - } - - if ( isNavigationKey === true ) { - - e.preventDefault(); - - // set focus on the checkbox - formElements[ $scope.tabIndex ].focus(); - var actEl = document.activeElement; - - if ( actEl.type.toUpperCase() === 'CHECKBOX' ) { - $scope.setFocusStyle( $scope.tabIndex ); - $scope.removeFocusStyle( prevTabIndex ); - } - else { - $scope.removeFocusStyle( prevTabIndex ); - $scope.removeFocusStyle( helperItemsLength ); - $scope.removeFocusStyle( formElements.length - 1 ); - } - } - - isNavigationKey = false; - } - - // set (add) CSS style on selected row - $scope.setFocusStyle = function( tabIndex ) { - angular.element( formElements[ tabIndex ] ).parent().parent().parent().addClass( 'multiSelectFocus' ); - } - - // remove CSS style on selected row - $scope.removeFocusStyle = function( tabIndex ) { - angular.element( formElements[ tabIndex ] ).parent().parent().parent().removeClass( 'multiSelectFocus' ); - } - - /********************* - ********************* - * - * 1) Initializations - * - ********************* - *********************/ - - // attrs to $scope - attrs-$scope - attrs - $scope - // Copy some properties that will be used on the template. They need to be in the $scope. - $scope.groupProperty = attrs.groupProperty; - $scope.tickProperty = attrs.tickProperty; - $scope.directiveId = attrs.directiveId; - - // Unfortunately I need to add these grouping properties into the input model - var tempStr = genRandomString( 5 ); - $scope.indexProperty = 'idx_' + tempStr; - $scope.spacingProperty = 'spc_' + tempStr; - - // set orientation css - if ( typeof attrs.orientation !== 'undefined' ) { - - if ( attrs.orientation.toUpperCase() === 'HORIZONTAL' ) { - $scope.orientationH = true; - $scope.orientationV = false; - } - else - { - $scope.orientationH = false; - $scope.orientationV = true; - } - } - - // get elements required for DOM operation - checkBoxLayer = element.children().children().next()[0]; - - // set max-height property if provided - if ( typeof attrs.maxHeight !== 'undefined' ) { - var layer = element.children().children().children()[0]; - angular.element( layer ).attr( "style", "height:" + attrs.maxHeight + "; overflow-y:scroll;" ); - } - - // some flags for easier checking - for ( var property in $scope.helperStatus ) { - if ( $scope.helperStatus.hasOwnProperty( property )) { - if ( - typeof attrs.helperElements !== 'undefined' - && attrs.helperElements.toUpperCase().indexOf( property.toUpperCase() ) === -1 - ) { - $scope.helperStatus[ property ] = false; - } - } - } - if ( typeof attrs.selectionMode !== 'undefined' && attrs.selectionMode.toUpperCase() === 'SINGLE' ) { - $scope.helperStatus[ 'all' ] = false; - $scope.helperStatus[ 'none' ] = false; - } - - // helper button icons.. I guess you can use html tag here if you want to. - $scope.icon = {}; - $scope.icon.selectAll = '✓'; // a tick icon - $scope.icon.selectNone = '×'; // x icon - $scope.icon.reset = '↶'; // undo icon - // this one is for the selected items - $scope.icon.tickMark = '✓'; // a tick icon - - // configurable button labels - // if ( typeof attrs.translation !== 'undefined' ) { - // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); - // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); - // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  ' + $scope.translation.reset ); - // $scope.lang.search = $scope.translation.search; - // $scope.lang.nothingSelected = $sce.trustAsHtml( $scope.translation.nothingSelected ); - // } - // else { - // $scope.lang.selectAll = $sce.trustAsHtml( $scope.icon.selectAll + '  Select All' ); - // $scope.lang.selectNone = $sce.trustAsHtml( $scope.icon.selectNone + '  Select None' ); - // $scope.lang.reset = $sce.trustAsHtml( $scope.icon.reset + '  Reset' ); - // $scope.lang.search = 'Search...'; - // $scope.lang.nothingSelected = 'None Selected'; - // } - // $scope.icon.tickMark = $sce.trustAsHtml( $scope.icon.tickMark ); - if ( typeof attrs.translation !== 'undefined' ) { - $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  ' + $scope.translation.selectAll ); - $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  ' + $scope.translation.selectNone ); - $scope.lang.reset = $sanitize( $scope.icon.reset + '  ' + $scope.translation.reset ); - $scope.lang.search = $scope.translation.search; - $scope.lang.nothingSelected = $sanitize( $scope.translation.nothingSelected ); - } - else { - $scope.lang.selectAll = $sanitize( $scope.icon.selectAll + '  Select All' ); - $scope.lang.selectNone = $sanitize( $scope.icon.selectNone + '  Select None' ); - $scope.lang.reset = $sanitize( $scope.icon.reset + '  Reset' ); - $scope.lang.search = 'Search...'; - $scope.lang.nothingSelected = 'None Selected'; - } - $scope.icon.tickMark = $sanitize( $scope.icon.tickMark ); - - // min length of keyword to trigger the filter function - if ( typeof attrs.MinSearchLength !== 'undefined' && parseInt( attrs.MinSearchLength ) > 0 ) { - vMinSearchLength = Math.floor( parseInt( attrs.MinSearchLength ) ); - } - - /******************************************************* - ******************************************************* - * - * 2) Logic starts here, initiated by watch 1 & watch 2 - * - ******************************************************* - *******************************************************/ - - // watch1, for changes in input model property - // updates multi-select when user select/deselect a single checkbox programatically - // https://github.com/isteven/angular-multi-select/issues/8 - $scope.$watch( 'inputModel' , function( newVal ) { - if ( newVal ) { - $scope.refreshOutputModel(); - $scope.refreshButton(); - } - }, true ); - - // watch2 for changes in input model as a whole - // this on updates the multi-select when a user load a whole new input-model. We also update the $scope.backUp variable - $scope.$watch( 'inputModel' , function( newVal ) { - if ( newVal ) { - $scope.backUp = angular.copy( $scope.inputModel ); - $scope.updateFilter(); - $scope.prepareGrouping(); - $scope.prepareIndex(); - $scope.refreshOutputModel(); - $scope.refreshButton(); - } - }); - - // watch for changes in directive state (disabled or enabled) - $scope.$watch( 'isDisabled' , function( newVal ) { - $scope.isDisabled = newVal; - }); - - // this is for touch enabled devices. We don't want to hide checkboxes on scroll. - var onTouchStart = function() { - $scope.$apply( function() { - $scope.scrolled = false; - }); - }; - angular.element( document ).bind( 'touchstart', onTouchStart); - var onTouchMove = function() { - $scope.$apply( function() { - $scope.scrolled = true; - }); - }; - angular.element( document ).bind( 'touchmove', onTouchMove); - - // unbind document events to prevent memory leaks - $scope.$on( '$destroy', function () { - angular.element( document ).unbind( 'touchstart', onTouchStart); - angular.element( document ).unbind( 'touchmove', onTouchMove); - }); - } - } -}]).run( [ '$templateCache' , function( $templateCache ) { - var template = - '' + - // main button - '' + - // overlay layer - '
' + - // container of the helper elements - '
' + - // container of the first 3 buttons, select all, none and reset - '
' + - // select all - ''+ - // select none - ''+ - // reset - '' + - '
' + - // the search box - '
'+ - // textfield - ''+ - // clear button - ' '+ - '
'+ - '
'+ - // selection items - '
'+ - '
'+ - // this is the spacing for grouped items - '
'+ - '
'+ - '
'+ - ''+ - '
'+ - // the tick/check mark - ''+ - '
'+ - '
'+ - '
'+ - '
'; - $templateCache.put( 'isteven-multi-select.htm' , template ); -}]); diff --git a/app/libraries/isteven-angular-multiselect/package.json b/app/libraries/isteven-angular-multiselect/package.json deleted file mode 100644 index 9aa2e3960..000000000 --- a/app/libraries/isteven-angular-multiselect/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "isteven-angular-multiselect", - "version": "v4.0.0", - "description": "A multi select dropdown directive for AngularJS", - "main": [ - "isteven-multi-select.js", - "isteven-multi-select.css" - ], - "repository": { - "type": "git", - "url": "https://github.com/isteven/angular-multi-select.git" - }, - "keywords": [ - "angular" - ], - "author": "Ignatius Steven (https://github.com/isteven)", - "license": "MIT", - "bugs": { - "url": "https://github.com/isteven/angular-multi-select/issues" - }, - "homepage": "https://github.com/isteven/angular-multi-select" -} diff --git a/app/vendors.js b/app/vendors.js index bf1f680dc..51ec8c035 100644 --- a/app/vendors.js +++ b/app/vendors.js @@ -12,6 +12,7 @@ import 'angular-json-tree/dist/angular-json-tree.css'; import 'angular-loading-bar/build/loading-bar.css'; import 'rdash-ui/dist/css/rdash.css'; import 'angular-moment-picker/dist/angular-moment-picker.min.css'; +import 'angular-multiselect/isteven-multi-select.css'; import angular from 'angular'; window.angular = angular; @@ -37,3 +38,4 @@ import 'bootstrap/dist/js/bootstrap.js'; import 'js-yaml/dist/js-yaml.js' import 'angular-ui-bootstrap'; import 'angular-moment-picker'; +import 'angular-multiselect/isteven-multi-select.js'; diff --git a/package.json b/package.json index e1cf4fdde..a1713a5f1 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "angular-messages": "~1.5.0", "angular-mocks": "~1.5.0", "angular-moment-picker": "^0.10.2", + "angular-multiselect": "github:portainer/angular-multi-select#semver:~v4.0.1", "angular-resource": "~1.5.0", "angular-sanitize": "~1.5.0", "angular-ui-bootstrap": "~2.5.0", diff --git a/yarn.lock b/yarn.lock index 55fb60149..d5911ee68 100644 --- a/yarn.lock +++ b/yarn.lock @@ -953,6 +953,11 @@ angular-messages@~1.5.0: resolved "https://registry.yarnpkg.com/angular-messages/-/angular-messages-1.5.11.tgz#ea99f0163594fcb0a2db701b3038339250decc90" integrity sha1-6pnwFjWU/LCi23AbMDgzklDezJA= +angular-mocks@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.6.1.tgz#2f44a1b3ac608e93751305bce176c274221d8abd" + integrity sha1-L0Shs6xgjpN1EwW84XbCdCIdir0= + angular-mocks@~1.5.0: version "1.5.11" resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.5.11.tgz#a0e1dd0ea55fd77ee7a757d75536c5e964c86f81" @@ -965,11 +970,23 @@ angular-moment-picker@^0.10.2: angular "^1.3" moment "^2.16.0" +"angular-multiselect@github:portainer/angular-multi-select#semver:~v4.0.1": + version "4.0.0" + resolved "https://codeload.github.com/portainer/angular-multi-select/tar.gz/933b066e0aa9e8879967a35a9c5bf7e89c72f86d" + dependencies: + angular-mocks "1.6.1" + angular-sanitize "1.6.1" + angular-resource@~1.5.0: version "1.5.11" resolved "https://registry.yarnpkg.com/angular-resource/-/angular-resource-1.5.11.tgz#d93ea619184a2e0ee3ae338265758363172929f0" integrity sha1-2T6mGRhKLg7jrjOCZXWDYxcpKfA= +angular-sanitize@1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.6.1.tgz#cacffec3199ed66297afbb1ef366ec66616b3b3f" + integrity sha1-ys/+wxme1mKXr7se82bsZmFrOz8= + angular-sanitize@~1.5.0: version "1.5.11" resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.5.11.tgz#ebfb3f343e543f9b2ef050fb4c2e9ee048d1772f" From 2445a5aed59b06c3c3826247463a7f5cb4d8afb4 Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 14 Oct 2019 15:45:09 +0200 Subject: [PATCH 04/47] fix(registry): Performance issues with Registry Manager (#2648) * fix(registry): fetch datatable details on page/filter/order state change instead of fetching all data on first load * fix(registry): fetch tags datatable details on state change instead of fetching all data on first load * fix(registry): add pagination support for tags + loading display on data load * fix(registry): debounce on text filter to avoid querying transient matching values * refactor(registry): rebase on latest develop * feat(registries): background tags and optimisation -- need code cleanup and for-await-of to cancel on page leave * refactor(registry-management): code cleanup * feat(registry): most optimized version -- need fix for add/retag * fix(registry): addTag working without page reload * fix(registry): retag working without reload * fix(registry): remove tag working without reload * fix(registry): remove repository working with latest changes * fix(registry): disable cache on firefox * feat(registry): use jquery for all 'most used' manifests requests * feat(registry): retag with progression + rewrite manifest REST service to jquery * fix(registry): remove forgotten DI * fix(registry): pagination on repository details * refactor(registry): info message + hidding images count until fetch has been done * fix(registry): fix selection reset deleting selectAll function and not resetting status * fix(registry): resetSelection was trying to set value on a getter * fix(registry): tags were dropped when too much tags were impacted by a tag removal * fix(registry): firefox add tag + progression * refactor(registry): rewording of elements * style(registry): add space between buttons and texts in status elements * fix(registry): cancelling a retag/delete action was not removing the status panel * fix(registry): tags count of empty repositories * feat(registry): reload page on action cancel to avoid desync * feat(registry): uncancellable modal on long operations * feat(registry): modal now closes on error + modal message improvement * feat(registries): remove empty repositories from the list * fix(registry): various bugfixes * feat(registry): independant timer on async actions + modal fix --- .babelrc | 2 +- .eslintrc.yml | 2 +- app/app.js | 14 +- app/docker/filters/filters.js | 3 + app/docker/helpers/imageHelper.js | 6 + .../registryRepositoriesDatatable.html | 11 +- .../registryRepositoriesDatatable.js | 5 +- ...registryRepositoriesDatatableController.js | 27 + .../registriesRepositoryTagsDatatable.html | 41 +- .../registriesRepositoryTagsDatatable.js | 7 +- ...stryRepositoriesTagsDatatableController.js | 31 ++ .../helpers/localRegistryHelper.js | 17 +- .../models/registryRepository.js | 12 +- .../models/repositoryTag.js | 22 +- .../registry-management/rest/manifest.js | 61 --- .../rest/manifestJquery.js | 89 ++++ .../registry-management/rest/tags.js | 5 +- .../services/genericAsyncGenerator.js | 34 ++ .../services/registryAPIService.js | 138 ----- .../services/registryV2Service.js | 214 ++++++++ .../progression-modal/progressionModal.html | 13 + .../progression-modal/progressionModal.js | 6 + .../repositories/edit/registryRepository.html | 49 +- .../edit/registryRepositoryController.js | 500 +++++++++++++----- .../repositories/registryRepositories.html | 4 +- .../registryRepositoriesController.js | 33 +- .../datatables/genericDatatableController.js | 5 + app/portainer/services/modalService.js | 14 + assets/css/app.css | 20 + package.json | 4 +- yarn.lock | 404 +++++++++++++- 31 files changed, 1372 insertions(+), 421 deletions(-) create mode 100644 app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatableController.js create mode 100644 app/extensions/registry-management/components/registries-repository-tags-datatable/registryRepositoriesTagsDatatableController.js delete mode 100644 app/extensions/registry-management/rest/manifest.js create mode 100644 app/extensions/registry-management/rest/manifestJquery.js create mode 100644 app/extensions/registry-management/services/genericAsyncGenerator.js delete mode 100644 app/extensions/registry-management/services/registryAPIService.js create mode 100644 app/extensions/registry-management/services/registryV2Service.js create mode 100644 app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.html create mode 100644 app/extensions/registry-management/views/repositories/edit/progression-modal/progressionModal.js diff --git a/.babelrc b/.babelrc index 32beabad5..6db8c8c05 100644 --- a/.babelrc +++ b/.babelrc @@ -5,7 +5,7 @@ "@babel/preset-env", { "modules": false, - "useBuiltIns": "usage" + "useBuiltIns": "entry" } ] ] diff --git a/.eslintrc.yml b/.eslintrc.yml index eb1b1779a..8e667de7c 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -24,7 +24,7 @@ rules: # no-cond-assign: error # no-console: off # no-constant-condition: error -# no-control-regex: error + no-control-regex: off # no-debugger: error # no-dupe-args: error # no-dupe-keys: error diff --git a/app/app.js b/app/app.js index acdee2add..db35643a2 100644 --- a/app/app.js +++ b/app/app.js @@ -1,8 +1,10 @@ import _ from 'lodash-es'; +import $ from 'jquery'; +import '@babel/polyfill' angular.module('portainer') -.run(['$rootScope', '$state', '$interval', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', -function ($rootScope, $state, $interval, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { +.run(['$rootScope', '$state', '$interval', 'LocalStorage', 'Authentication', 'authManager', 'StateManager', 'EndpointProvider', 'Notifications', 'Analytics', 'SystemService', 'cfpLoadingBar', '$transitions', 'HttpRequestHelper', +function ($rootScope, $state, $interval, LocalStorage, Authentication, authManager, StateManager, EndpointProvider, Notifications, Analytics, SystemService, cfpLoadingBar, $transitions, HttpRequestHelper) { 'use strict'; EndpointProvider.initialize(); @@ -40,6 +42,14 @@ function ($rootScope, $state, $interval, Authentication, authManager, StateManag ping(EndpointProvider, SystemService); }, 60 * 1000) + $(document).ajaxSend(function (event, jqXhr, jqOpts) { + const type = jqOpts.type === 'POST' || jqOpts.type === 'PUT' || jqOpts.type === 'PATCH'; + const hasNoContentType = jqOpts.contentType !== 'application/json' && jqOpts.headers && !jqOpts.headers['Content-Type']; + if (type && hasNoContentType) { + jqXhr.setRequestHeader('Content-Type', 'application/json'); + } + jqXhr.setRequestHeader('Authorization', 'Bearer ' + LocalStorage.getJWT()); + }); }]); function ping(EndpointProvider, SystemService) { diff --git a/app/docker/filters/filters.js b/app/docker/filters/filters.js index 3ba22ef50..3fd65f22a 100644 --- a/app/docker/filters/filters.js +++ b/app/docker/filters/filters.js @@ -278,6 +278,9 @@ angular.module('portainer.docker') .filter('trimshasum', function () { 'use strict'; return function (imageName) { + if (!imageName) { + return; + } if (imageName.indexOf('sha256:') === 0) { return imageName.substring(7, 19); } diff --git a/app/docker/helpers/imageHelper.js b/app/docker/helpers/imageHelper.js index b46951c83..78091aa5e 100644 --- a/app/docker/helpers/imageHelper.js +++ b/app/docker/helpers/imageHelper.js @@ -6,6 +6,12 @@ angular.module('portainer.docker') var helper = {}; + helper.isValidTag = isValidTag; + + function isValidTag(tag) { + return tag.match(/^(?![\.\-])([a-zA-Z0-9\_\.\-])+$/g); + } + helper.extractImageAndRegistryFromRepository = function(repository) { var slashCount = _.countBy(repository)['/']; var registry = null; diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html index 8745cc2c9..b076acffd 100644 --- a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -8,7 +8,7 @@
@@ -22,16 +22,12 @@ - - + @@ -59,7 +55,6 @@ Items per page +
- Tags count - - -
{{ item.TagsCount }}
Loading...
@@ -32,25 +31,13 @@ - - - + + + - - + - - - + + - - + +
Os/Architecture - - Image ID - - - - - - Size - - - - ActionsImage IDCompressed sizeActions
@@ -60,15 +47,16 @@ {{ item.Name }} {{ item.Os }}/{{ item.Architecture }}{{ item.ImageId | truncate:40 }}{{ item.ImageId | trimshasum }} {{ item.Size | humansize }} + Retag + @@ -76,11 +64,11 @@
Loading...
Loading...
No tag available.
No tag available.
@@ -96,7 +84,6 @@ Items per page
@@ -58,10 +85,10 @@ - {{ $select.selected }} + {{ $select.selected | trimshasum }} - - {{ image }} + + {{ image | trimshasum }}
@@ -83,6 +110,10 @@
+ order-by="Name" remove-action="removeTags" retag-action="retagAction" + advanced-features-available="short.Images.length > 0" + pagination-action="paginationAction" + loading="state.loading"> +
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js index 03f0b961d..b37856c54 100644 --- a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js +++ b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js @@ -1,105 +1,335 @@ import _ from 'lodash-es'; +import { RepositoryTagViewModel, RepositoryShortTag } from '../../../models/repositoryTag'; angular.module('portainer.app') - .controller('RegistryRepositoryController', ['$q', '$scope', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', - function ($q, $scope, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications) { + .controller('RegistryRepositoryController', ['$q', '$async', '$scope', '$uibModal', '$interval', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', 'ImageHelper', + function ($q, $async, $scope, $uibModal, $interval, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications, ImageHelper) { $scope.state = { - actionInProgress: false + actionInProgress: false, + loading: false, + tagsRetrieval: { + auto: true, + running: false, + limit: 100, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, + tagsRetag: { + running: false, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, + tagsDelete: { + running: false, + progression: 0, + elapsedTime: 0, + asyncGenerator: null, + clock: null + }, }; $scope.formValues = { - Tag: '' + Tag: '' // new tag name on add feature + }; + $scope.tags = []; // RepositoryTagViewModel (for datatable) + $scope.short = { + Tags: [], // RepositoryShortTag + Images: [] // strings extracted from short.Tags }; - $scope.tags = []; $scope.repository = { - Name: [], - Tags: [], - Images: [] + Name: '', + Tags: [], // string list }; - $scope.$watch('tags.length', function () { - var images = $scope.tags.map(function (item) { - return item.ImageId; + function toSeconds(time) { + return time / 1000; + } + function toPercent(progress, total) { + return (progress / total * 100).toFixed(); + } + + function openModal(resolve) { + return $uibModal.open({ + component: 'progressionModal', + backdrop: 'static', + keyboard: false, + resolve: resolve }); - $scope.repository.Images = _.uniq(images); - }); + } + + $scope.paginationAction = function (tags) { + $scope.state.loading = true; + RegistryV2Service.getTagsDetails($scope.registryId, $scope.repository.Name, tags) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var idx = _.findIndex($scope.tags, {'Name': data[i].Name}); + if (idx !== -1) { + $scope.tags[idx] = data[i]; + } + } + $scope.state.loading = false; + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve tags details'); + }); + }; + + /** + * RETRIEVAL SECTION + */ + function updateRetrievalClock(startTime) { + $scope.state.tagsRetrieval.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createRetrieveAsyncGenerator() { + $scope.state.tagsRetrieval.asyncGenerator = + RegistryV2Service.shortTagsWithProgress($scope.registryId, $scope.repository.Name, $scope.repository.Tags); + } + + function resetTagsRetrievalState() { + $scope.state.tagsRetrieval.running = false; + $scope.state.tagsRetrieval.progression = 0; + $scope.state.tagsRetrieval.elapsedTime = 0; + $scope.state.tagsRetrieval.clock = null; + } + + function computeImages() { + const images = _.map($scope.short.Tags, 'ImageId'); + $scope.short.Images = _.without(_.uniq(images), ''); + } + + $scope.startStopRetrieval = function () { + if ($scope.state.tagsRetrieval.running) { + $scope.state.tagsRetrieval.asyncGenerator.return(); + $interval.cancel($scope.state.tagsRetrieval.clock); + } else { + retrieveTags().then(() => { + createRetrieveAsyncGenerator(); + if ($scope.short.Tags.length === 0) { + resetTagsRetrievalState(); + } else { + computeImages(); + } + }); + } + }; + + function retrieveTags() { + return $async(retrieveTagsAsync); + } + + async function retrieveTagsAsync() { + $scope.state.tagsRetrieval.running = true; + const startTime = Date.now(); + $scope.state.tagsRetrieval.clock = $interval(updateRetrievalClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsRetrieval.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsRetrieval.progression = toPercent(partialResult, $scope.repository.Tags.length); + } else { + $scope.short.Tags = _.sortBy(partialResult, 'Name'); + } + } + $scope.state.tagsRetrieval.running = false; + $interval.cancel($scope.state.tagsRetrieval.clock); + } + /** + * !END RETRIEVAL SECTION + */ + + /** + * ADD TAG SECTION + */ + + async function addTagAsync() { + try { + $scope.state.actionInProgress = true; + if (!ImageHelper.isValidTag($scope.formValues.Tag)) { + throw {msg: 'Invalid tag pattern, see info for more details on format.'} + } + const tag = $scope.short.Tags.find((item) => item.ImageId === $scope.formValues.SelectedImage); + const manifest = tag.ManifestV2; + await RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, {tag: $scope.formValues.Tag, manifest: manifest}) + + Notifications.success('Success', 'Tag successfully added'); + $scope.short.Tags.push(new RepositoryShortTag($scope.formValues.Tag, tag.ImageId, tag.ImageDigest, tag.ManifestV2)); + + await loadRepositoryDetails(); + $scope.formValues.Tag = ''; + delete $scope.formValues.SelectedImage; + } catch (err) { + Notifications.error('Failure', err, 'Unable to add tag'); + } finally { + $scope.state.actionInProgress = false; + } + } $scope.addTag = function () { - var manifest = $scope.tags.find(function (item) { - return item.ImageId === $scope.formValues.SelectedImage; - }).ManifestV2; - RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, $scope.formValues.Tag, manifest) - .then(function success() { - Notifications.success('Success', 'Tag successfully added'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to add tag'); - }); + return $async(addTagAsync); }; + /** + * !END ADD TAG SECTION + */ - $scope.retagAction = function (tag) { - RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, tag.Digest) - .then(function success() { - var promises = []; - var tagsToAdd = $scope.tags.filter(function (item) { - return item.Digest === tag.Digest; - }); - tagsToAdd.map(function (item) { - var tagValue = item.Modified && item.Name !== item.NewName ? item.NewName : item.Name; - promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, tagValue, item.ManifestV2)); - }); - return $q.all(promises); - }) - .then(function success() { - Notifications.success('Success', 'Tag successfully modified'); - $state.reload(); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to modify tag'); - tag.Modified = false; - tag.NewValue = tag.Value; + /** + * RETAG SECTION + */ + function updateRetagClock(startTime) { + $scope.state.tagsRetag.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags) { + $scope.state.tagsRetag.asyncGenerator = + RegistryV2Service.retagWithProgress($scope.registryId, $scope.repository.Name, modifiedTags, modifiedDigests, impactedTags); + } + + async function retagActionAsync() { + let modal = null; + try { + $scope.state.tagsRetag.running = true; + + const modifiedTags = _.filter($scope.tags, (item) => item.Modified === true); + for (const tag of modifiedTags) { + if (!ImageHelper.isValidTag(tag.NewName)) { + throw {msg: 'Invalid tag pattern, see info for more details on format.'} + } + } + modal = await openModal({ + message: () => 'Retag is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.', + progressLabel: () => 'Retag progress', + context: () => $scope.state.tagsRetag }); - }; + const modifiedDigests = _.uniq(_.map(modifiedTags, 'ImageDigest')); + const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest)); - $scope.removeTags = function (selectedItems) { + const totalOps = modifiedDigests.length + impactedTags.length; + + createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags); + + const startTime = Date.now(); + $scope.state.tagsRetag.clock = $interval(updateRetagClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsRetag.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsRetag.progression = toPercent(partialResult, totalOps); + } + } + + _.map(modifiedTags, (item) => { + const idx = _.findIndex($scope.short.Tags, (i) => i.Name === item.Name); + $scope.short.Tags[idx].Name = item.NewName; + }); + + Notifications.success('Success', 'Tags successfully renamed'); + + await loadRepositoryDetails(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to rename tags'); + } finally { + $interval.cancel($scope.state.tagsRetag.clock); + $scope.state.tagsRetag.running = false; + if (modal) { + modal.close(); + } + } + } + + $scope.retagAction = function() { + return $async(retagActionAsync); + } + /** + * !END RETAG SECTION + */ + + /** + * REMOVE TAGS SECTION + */ + + function updateDeleteClock(startTime) { + $scope.state.tagsDelete.elapsedTime = toSeconds(Date.now() - startTime); + } + + function createDeleteAsyncGenerator(modifiedDigests, impactedTags) { + $scope.state.tagsDelete.asyncGenerator = + RegistryV2Service.deleteTagsWithProgress($scope.registryId, $scope.repository.Name, modifiedDigests, impactedTags); + } + + async function removeTagsAsync(selectedTags) { + let modal = null; + try { + $scope.state.tagsDelete.running = true; + modal = await openModal({ + message: () => 'Tag delete is in progress! Closing your browser or refreshing the page while this operation is in progress will result in loss of tags.', + progressLabel: () => 'Deletion progress', + context: () => $scope.state.tagsDelete + }); + + const deletedTagNames = _.map(selectedTags, 'Name'); + const deletedShortTags = _.filter($scope.short.Tags, (item) => _.includes(deletedTagNames, item.Name)); + const modifiedDigests = _.uniq(_.map(deletedShortTags, 'ImageDigest')); + const impactedTags = _.filter($scope.short.Tags, (item) => _.includes(modifiedDigests, item.ImageDigest)); + const tagsToKeep = _.without(impactedTags, ...deletedShortTags); + + const totalOps = modifiedDigests.length + tagsToKeep.length; + + createDeleteAsyncGenerator(modifiedDigests, tagsToKeep); + + const startTime = Date.now(); + $scope.state.tagsDelete.clock = $interval(updateDeleteClock, 1000, 0, true, startTime); + for await (const partialResult of $scope.state.tagsDelete.asyncGenerator) { + if (typeof partialResult === 'number') { + $scope.state.tagsDelete.progression = toPercent(partialResult, totalOps); + } + } + + _.pull($scope.short.Tags, ...deletedShortTags); + $scope.short.Images = _.map(_.uniqBy($scope.short.Tags, 'ImageId'), 'ImageId'); + + Notifications.success('Success', 'Tags successfully deleted'); + + if ($scope.short.Tags.length === 0) { + $state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true}); + } + await loadRepositoryDetails(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to delete tags'); + } finally { + $interval.cancel($scope.state.tagsDelete.clock); + $scope.state.tagsDelete.running = false; + modal.close(); + } + } + + $scope.removeTags = function(selectedItems) { ModalService.confirmDeletion( 'Are you sure you want to remove the selected tags ?', - function onConfirm(confirmed) { + (confirmed) => { if (!confirmed) { return; } - var promises = []; - var uniqItems = _.uniqBy(selectedItems, 'Digest'); - uniqItems.map(function (item) { - promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); - }); - $q.all(promises) - .then(function success() { - var promises = []; - var tagsToReupload = _.differenceBy($scope.tags, selectedItems, 'Name'); - tagsToReupload.map(function (item) { - promises.push(RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, item.Name, item.ManifestV2)); - }); - return $q.all(promises); - }) - .then(function success(data) { - Notifications.success('Success', 'Tags successfully deleted'); - if (data.length === 0) { - $state.go('portainer.registries.registry.repositories', { - id: $scope.registryId - }, { - reload: true - }); - } else { - $state.reload(); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to delete tags'); - }); + return $async(removeTagsAsync, selectedItems); }); - }; + } + /** + * !END REMOVE TAGS SECTION + */ + + /** + * REMOVE REPOSITORY SECTION + */ + async function removeRepositoryAsync() { + try { + const digests = _.uniqBy($scope.short.Tags, 'ImageDigest'); + const promises = []; + _.map(digests, (item) => promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.ImageDigest))); + await Promise.all(promises); + Notifications.success('Success', 'Repository sucessfully removed'); + $state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true}); + } catch (err) { + Notifications.error('Failure', err, 'Unable to delete repository'); + } + } $scope.removeRepository = function () { ModalService.confirmDeletion( @@ -108,53 +338,81 @@ angular.module('portainer.app') if (!confirmed) { return; } - var promises = []; - var uniqItems = _.uniqBy($scope.tags, 'Digest'); - uniqItems.map(function (item) { - promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.Digest)); - }); - $q.all(promises) - .then(function success() { - Notifications.success('Success', 'Repository sucessfully removed'); - $state.go('portainer.registries.registry.repositories', { - id: $scope.registryId - }, { - reload: true - }); - }).catch(function error(err) { - Notifications.error('Failure', err, 'Unable to delete repository'); - }); + return $async(removeRepositoryAsync); } ); }; + /** + * !END REMOVE REPOSITORY SECTION + */ - function initView() { - var registryId = $scope.registryId = $transition$.params().id; - var repository = $scope.repository.Name = $transition$.params().repository; - $q.all({ - registry: RegistryService.registry(registryId), - tags: RegistryV2Service.tags(registryId, repository) - }) - .then(function success(data) { - $scope.registry = data.registry; - $scope.repository.Tags = [].concat(data.tags || []); - $scope.tags = []; - for (var i = 0; i < $scope.repository.Tags.length; i++) { - var tag = data.tags[i]; - RegistryV2Service.tag(registryId, repository, tag) - .then(function success(data) { - $scope.tags.push(data); - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve tag information'); - }); - } - }) - .catch(function error(err) { - Notifications.error('Failure', err, 'Unable to retrieve repository information'); - }); + /** + * INIT SECTION + */ + async function loadRepositoryDetails() { + try { + const registryId = $scope.registryId; + const repository = $scope.repository.Name; + const tags = await RegistryV2Service.tags(registryId, repository); + $scope.tags = []; + $scope.repository.Tags = []; + $scope.repository.Tags = _.sortBy(_.concat($scope.repository.Tags, _.without(tags.tags, null))); + _.map($scope.repository.Tags, (item) => $scope.tags.push(new RepositoryTagViewModel(item))); + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve tags details'); + } } - initView(); + async function initView() { + try { + const registryId = $scope.registryId = $transition$.params().id; + $scope.repository.Name = $transition$.params().repository; + $scope.state.loading = true; + + $scope.registry = await RegistryService.registry(registryId); + await loadRepositoryDetails(); + if ($scope.repository.Tags.length > $scope.state.tagsRetrieval.limit) { + $scope.state.tagsRetrieval.auto = false; + } + createRetrieveAsyncGenerator(); + } catch (err) { + Notifications.error('Failure', err, 'Unable to retrieve repository information'); + } finally { + $scope.state.loading = false; + } + } + + $scope.$on('$destroy', () => { + if ($scope.state.tagsRetrieval.asyncGenerator) { + $scope.state.tagsRetrieval.asyncGenerator.return(); + } + if ($scope.state.tagsRetrieval.clock) { + $interval.cancel($scope.state.tagsRetrieval.clock); + } + if ($scope.state.tagsRetag.asyncGenerator) { + $scope.state.tagsRetag.asyncGenerator.return(); + } + if ($scope.state.tagsRetag.clock) { + $interval.cancel($scope.state.tagsRetag.clock); + } + if ($scope.state.tagsDelete.asyncGenerator) { + $scope.state.tagsDelete.asyncGenerator.return(); + } + if ($scope.state.tagsDelete.clock) { + $interval.cancel($scope.state.tagsDelete.clock); + } + }); + + this.$onInit = function() { + return $async(initView) + .then(() => { + if ($scope.state.tagsRetrieval.auto) { + $scope.startStopRetrieval(); + } + }); + }; + /** + * !END INIT SECTION + */ } - ]); + ]); \ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/registryRepositories.html b/app/extensions/registry-management/views/repositories/registryRepositories.html index 5e3210ca7..ec145445a 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositories.html +++ b/app/extensions/registry-management/views/repositories/registryRepositories.html @@ -5,7 +5,7 @@ - Registries > {{ registry.Name }}{{ registry.Name}} > Repositories + Registries > {{ registry.Name }}{{ registry.Name}} > Repositories @@ -31,7 +31,7 @@ + order-by="Name" pagination-action="paginationAction" loading="state.loading"> diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js index 0ff931eff..eace4f9f3 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -1,26 +1,49 @@ +import _ from 'lodash-es'; + angular.module('portainer.extensions.registrymanagement') .controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', 'Authentication', function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications, Authentication) { $scope.state = { - displayInvalidConfigurationMessage: false + displayInvalidConfigurationMessage: false, + loading: false + }; + + $scope.paginationAction = function (repositories) { + $scope.state.loading = true; + RegistryV2Service.getRepositoriesDetails($scope.state.registryId, repositories) + .then(function success(data) { + for (var i = 0; i < data.length; i++) { + var idx = _.findIndex($scope.repositories, {'Name': data[i].Name}); + if (idx !== -1) { + if (data[i].TagsCount === 0) { + $scope.repositories.splice(idx, 1); + } else { + $scope.repositories[idx].TagsCount = data[i].TagsCount; + } + } + } + $scope.state.loading = false; + }).catch(function error(err) { + Notifications.error('Failure', err, 'Unable to retrieve repositories details'); + }); }; function initView() { - var registryId = $transition$.params().id; + $scope.state.registryId = $transition$.params().id; var authenticationEnabled = $scope.applicationState.application.authentication; if (authenticationEnabled) { $scope.isAdmin = Authentication.isAdmin(); } - RegistryService.registry(registryId) + RegistryService.registry($scope.state.registryId) .then(function success(data) { $scope.registry = data; - RegistryV2Service.ping(registryId, false) + RegistryV2Service.ping($scope.state.registryId, false) .then(function success() { - return RegistryV2Service.repositories(registryId); + return RegistryV2Service.catalog($scope.state.registryId); }) .then(function success(data) { $scope.repositories = data; diff --git a/app/portainer/components/datatables/genericDatatableController.js b/app/portainer/components/datatables/genericDatatableController.js index 4658c03ea..7ba8d75ce 100644 --- a/app/portainer/components/datatables/genericDatatableController.js +++ b/app/portainer/components/datatables/genericDatatableController.js @@ -27,6 +27,11 @@ function ($interval, PaginationService, DatatableService, PAGINATION_MAX_ITEMS) refreshRate: '30' } } + this.resetSelectionState = function() { + this.state.selectAll = false; + this.state.selectedItems = []; + _.map(this.state.filteredDataSet, (item) => item.Checked = false); + }; this.onTextFilterChange = function() { DatatableService.setDataTableTextFilters(this.tableKey, this.state.textFilter); diff --git a/app/portainer/services/modalService.js b/app/portainer/services/modalService.js index 4b896859d..9fa9cf1bc 100644 --- a/app/portainer/services/modalService.js +++ b/app/portainer/services/modalService.js @@ -98,6 +98,20 @@ angular.module('portainer.app') }); }; + service.cancelRegistryRepositoryAction = function(callback) { + service.confirm({ + title: 'Are you sure?', + message: 'WARNING: interrupting this operation before it has finished will result in the loss of all tags. Are you sure you want to do this?', + buttons: { + confirm: { + label: 'Stop', + className: 'btn-danger' + } + }, + callback: callback + }); + }; + service.confirmDeletion = function(message, callback) { message = $sanitize(message); service.confirm({ diff --git a/assets/css/app.css b/assets/css/app.css index 543eed1f7..f51d3576a 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -834,6 +834,26 @@ ul.sidebar .sidebar-list .sidebar-sublist a.active { margin: 20px auto 10px auto; } +.modal { + text-align: center; + padding: 0!important; +} + +.modal::before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + margin-right: -4px; +} + +.modal-dialog { + display: inline-block; + text-align: left; + vertical-align: middle; +} + + /*bootbox override*/ .modal-open { padding-right: 0 !important; diff --git a/package.json b/package.json index a1713a5f1..aee21f9ac 100644 --- a/package.json +++ b/package.json @@ -95,8 +95,8 @@ "clean-webpack-plugin": "^0.1.19", "css-loader": "^1.0.0", "cssnano": "^3.10.0", - "eslint": "^3.19.0", - "eslint-loader": "^2.1.1", + "eslint": "5.16.0", + "eslint-loader": "^2.1.2", "file-loader": "^1.1.11", "grunt": "~0.4.0", "grunt-cli": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index d5911ee68..94f426889 100644 --- a/yarn.lock +++ b/yarn.lock @@ -830,6 +830,11 @@ acorn-jsx@^3.0.0: dependencies: acorn "^3.0.4" +acorn-jsx@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.0.1.tgz#32a064fd925429216a09b141102bfdd185fae40e" + integrity sha512-HJ7CfNHrfJLlNTzIEUTj43LNWGkqpRLxm3YjAlcD0ACydk9XynzYsCBHxut+iqt+1aBXkx9UP/w/ZqMr13XIzg== + acorn@^3.0.4: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" @@ -845,6 +850,11 @@ acorn@^5.2.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.2.1.tgz#317ac7821826c22c702d66189ab8359675f135d7" integrity sha512-jG0u7c4Ly+3QkkW18V+NRDN+4bWHdln30NL1ZL2AvFZZmQe/BfopYCtghCKKVBUSetZ4QKcyA0pY6/4Gw8Pv8w== +acorn@^6.0.7: + version "6.1.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.1.1.tgz#7d25ae05bb8ad1f9b699108e1094ecd7884adc1f" + integrity sha512-jPTiwtOxaHNaAPg/dmrJ/beuzLRnXtB0kQPQ8JpotKJgTB6rX6c8mlf315941pyjBSaPg8NHXS9fhP4u17DpGA== + active-x-obfuscator@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/active-x-obfuscator/-/active-x-obfuscator-0.0.1.tgz#089b89b37145ff1d9ec74af6530be5526cae1f1a" @@ -885,6 +895,16 @@ ajv@^6.1.0: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^6.9.1: + version "6.10.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.0.tgz#90d0d54439da587cd7e843bfb7045f50bd22bdf1" + integrity sha512-nffhOpkymDECQyR0mnsUtoCE8RlX38G0rYP+wgLWFyZuUyuuojSSvi/+euOiQBIn63whYwYVIIH1TvE3tu4OEg== + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + align-text@^0.1.1, align-text@^0.1.3: version "0.1.4" resolved "https://registry.yarnpkg.com/align-text/-/align-text-0.1.4.tgz#0cd90a561093f35d0a99256c22b7069433fad117" @@ -1031,6 +1051,11 @@ ansi-escapes@^1.1.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-1.4.0.tgz#d3a8a83b319aa67793662b13e761c7911422306e" integrity sha1-06ioOzGapneTZisT52HHkRQiMG4= +ansi-escapes@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" + integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== + ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" @@ -1046,6 +1071,11 @@ ansi-regex@^3.0.0: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-3.0.0.tgz#ed0317c322064f79466c02966bddb605ab37d998" integrity sha1-7QMXwyIGT3lGbAKWa922Bas32Zg= +ansi-regex@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-4.1.0.tgz#8b9f8f08cf1acb843756a839ca8c7e3168c51997" + integrity sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg== + ansi-styles@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-2.2.1.tgz#b432dd3358b634cf75e1e4664368240533c1ddbe" @@ -1058,7 +1088,7 @@ ansi-styles@^3.1.0: dependencies: color-convert "^1.9.0" -ansi-styles@^3.2.1: +ansi-styles@^3.2.0, ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== @@ -1254,6 +1284,11 @@ ast-types@0.9.6: resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.9.6.tgz#102c9e9e9005d3e7e3829bf0c4fa24ee862ee9b9" integrity sha1-ECyenpAF0+fjgpvwxPok7oYu6bk= +astral-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" + integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== + async-done@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/async-done/-/async-done-0.4.0.tgz#ab8053f5f62290f8bfc58f37cd9b73070b3307b9" @@ -1954,6 +1989,11 @@ callsites@^0.2.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-0.2.0.tgz#afab96262910a7f33c19a5775825c69f34e350ca" integrity sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo= +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + camel-case@3.0.x: version "3.0.0" resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-3.0.0.tgz#ca3c3688a4e9cf3a4cda777dc4dcbc713249cf73" @@ -2063,6 +2103,15 @@ chalk@^2.0.0, chalk@^2.0.1, chalk@^2.4.1: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^2.1.0, chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + chalk@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.0.tgz#b5ea48efc9c1793dccc9b4767c93914d3f2d52ba" @@ -2072,6 +2121,11 @@ chalk@^2.3.0: escape-string-regexp "^1.0.5" supports-color "^4.0.0" +chardet@^0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" + integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== + chart.js@~2.6.0: version "2.6.0" resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-2.6.0.tgz#308f9a4b0bfed5a154c14f5deb1d9470d22abe71" @@ -2210,6 +2264,13 @@ cli-cursor@^1.0.1: dependencies: restore-cursor "^1.0.1" +cli-cursor@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5" + integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU= + dependencies: + restore-cursor "^2.0.0" + cli-width@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" @@ -3022,6 +3083,13 @@ debug@^3.1.0, debug@^3.2.5: dependencies: ms "^2.1.1" +debug@^4.0.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" + integrity sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw== + dependencies: + ms "^2.1.1" + debug@~2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/debug/-/debug-2.2.0.tgz#f87057e995b1a1f6ae6a4960664137bc56f039da" @@ -3368,6 +3436,13 @@ doctrine@^2.0.0: dependencies: esutils "^2.0.2" +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + dom-converter@~0.2: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -3576,6 +3651,11 @@ elliptic@^6.0.0: minimalistic-assert "^1.0.0" minimalistic-crypto-utils "^1.0.0" +emoji-regex@^7.0.1: + version "7.0.3" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" + integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== + emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" @@ -3769,10 +3849,10 @@ escope@^3.6.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-loader@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-2.1.1.tgz#2a9251523652430bfdd643efdb0afc1a2a89546a" - integrity sha512-1GrJFfSevQdYpoDzx8mEE2TDWsb/zmFuY09l6hURg1AeFIKQOvZ+vH0UPjzmd1CZIbfTV5HUkMeBmFiDBkgIsQ== +eslint-loader@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/eslint-loader/-/eslint-loader-2.1.2.tgz#453542a1230d6ffac90e4e7cb9cadba9d851be68" + integrity sha512-rA9XiXEOilLYPOIInvVH5S/hYfyTPyxag6DZhoQOduM+3TkghAEQ3VcFO8VnX4J4qg/UIBzp72aOf/xvYmpmsg== dependencies: loader-fs-cache "^1.0.0" loader-utils "^1.0.2" @@ -3788,7 +3868,67 @@ eslint-scope@^4.0.0: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint@^3.0.0, eslint@^3.19.0: +eslint-scope@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" + integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + +eslint-utils@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-1.3.1.tgz#9a851ba89ee7c460346f97cf8939c7298827e512" + integrity sha512-Z7YjnIldX+2XMcjr7ZkgEsOj/bREONV60qYeB/bjMAqqqZ4zxKyWX+BOUkdmRmA9riiIPVvo5x86m5elviOk0Q== + +eslint-visitor-keys@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#3f3180fb2e291017716acb4c9d6d5b5c34a6a81d" + integrity sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ== + +eslint@5.16.0: + version "5.16.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-5.16.0.tgz#a1e3ac1aae4a3fbd8296fcf8f7ab7314cbb6abea" + integrity sha512-S3Rz11i7c8AA5JPv7xAH+dOyq/Cu/VXHiHXBPOU1k/JAM5dXqQPt3qcrhpHSorXmrpu2g0gkIBVXAqCpzfoZIg== + dependencies: + "@babel/code-frame" "^7.0.0" + ajv "^6.9.1" + chalk "^2.1.0" + cross-spawn "^6.0.5" + debug "^4.0.1" + doctrine "^3.0.0" + eslint-scope "^4.0.3" + eslint-utils "^1.3.1" + eslint-visitor-keys "^1.0.0" + espree "^5.0.1" + esquery "^1.0.1" + esutils "^2.0.2" + file-entry-cache "^5.0.1" + functional-red-black-tree "^1.0.1" + glob "^7.1.2" + globals "^11.7.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + inquirer "^6.2.2" + js-yaml "^3.13.0" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.3.0" + lodash "^4.17.11" + minimatch "^3.0.4" + mkdirp "^0.5.1" + natural-compare "^1.4.0" + optionator "^0.8.2" + path-is-inside "^1.0.2" + progress "^2.0.0" + regexpp "^2.0.1" + semver "^5.5.1" + strip-ansi "^4.0.0" + strip-json-comments "^2.0.1" + table "^5.2.3" + text-table "^0.2.0" + +eslint@^3.0.0: version "3.19.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-3.19.0.tgz#c8fc6201c7f40dd08941b87c085767386a679acc" integrity sha1-yPxiAcf0DdCJQbh8CFdnOGpnmsw= @@ -3837,6 +3977,15 @@ espree@^3.4.0: acorn "^5.2.1" acorn-jsx "^3.0.0" +espree@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-5.0.1.tgz#5d6526fa4fc7f0788a5cf75b15f30323e2f81f7a" + integrity sha512-qWAZcWh4XE/RwzLJejfcofscgMc9CamR6Tn1+XRXNzrvUSSbiAjGOI/fggztjIi7y9VLPqnICMIPiGyr8JaZ0A== + dependencies: + acorn "^6.0.7" + acorn-jsx "^5.0.0" + eslint-visitor-keys "^1.0.0" + esprima@1.0.x, "esprima@~ 1.0.2", esprima@~1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/esprima/-/esprima-1.0.4.tgz#9f557e08fc3b4d26ece9dd34f8fbf476b62585ad" @@ -3864,6 +4013,13 @@ esquery@^1.0.0: dependencies: estraverse "^4.0.0" +esquery@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.0.1.tgz#406c51658b1f5991a5f9b62b1dc25b00e3e5c708" + integrity sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA== + dependencies: + estraverse "^4.0.0" + esrecurse@^4.1.0: version "4.2.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.2.0.tgz#fa9568d98d3823f9a41d91e902dcab9ea6e5b163" @@ -4157,6 +4313,15 @@ extend@^3.0.0: resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.1.tgz#a755ea7bc1adfcc5a31ce7e762dbaadc5e636444" integrity sha1-p1Xqe8Gt/MWjHOfnYtuq3F5jZEQ= +external-editor@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.0.3.tgz#5866db29a97826dbe4bf3afd24070ead9ea43a27" + integrity sha512-bn71H9+qWoOQKyZDo25mOMVpSmXROAsTJVVVYzrrtol3d4y+AsKjf4Iwl2Q+IuT0kFSQ1qo166UuIwqYq7mGnA== + dependencies: + chardet "^0.7.0" + iconv-lite "^0.4.24" + tmp "^0.0.33" + extglob@^0.3.1: version "0.3.2" resolved "https://registry.yarnpkg.com/extglob/-/extglob-0.3.2.tgz#2e18ff3d2f49ab2765cec9023f011daa8d8349a1" @@ -4245,6 +4410,13 @@ figures@^1.0.1, figures@^1.3.5: escape-string-regexp "^1.0.5" object-assign "^4.1.0" +figures@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962" + integrity sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI= + dependencies: + escape-string-regexp "^1.0.5" + file-entry-cache@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-2.0.0.tgz#c392990c3e684783d838b8c84a45d8a048458361" @@ -4253,6 +4425,13 @@ file-entry-cache@^2.0.0: flat-cache "^1.2.1" object-assign "^4.0.1" +file-entry-cache@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" + integrity sha512-bCg29ictuBaKUwwArK4ouCaqDgLZcysCFLmM/Yn/FDoqndh/9vNuQfXRDvTuXKLxfD/JtZQGKFT8MGcJBK644g== + dependencies: + flat-cache "^2.0.1" + file-loader@^1.1.11: version "1.1.11" resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-1.1.11.tgz#6fe886449b0f2a936e43cabaac0cdbfb369506f8" @@ -4519,6 +4698,20 @@ flat-cache@^1.2.1: graceful-fs "^4.1.2" write "^0.2.1" +flat-cache@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-2.0.1.tgz#5d296d6f04bda44a4630a301413bdbc2ec085ec0" + integrity sha512-LoQe6yDuUMDzQAEH8sgmh4Md6oZnc/7PjtwjNFSzveXqSHt6ka9fPBuso7IGf9Rz4uqnSnWiFH2B/zj24a5ReA== + dependencies: + flatted "^2.0.0" + rimraf "2.6.3" + write "1.0.3" + +flatted@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.0.tgz#55122b6536ea496b4b44893ee2608141d10d9916" + integrity sha512-R+H8IZclI8AAkSBRQJLVOsxwAoHd6WC40b4QTNWIjzAa6BXOBfQcM587MXDTVPeYaopFNWHUFLx7eNmHDSxMWg== + flatten@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/flatten/-/flatten-1.0.2.tgz#dae46a9d78fbe25292258cc1e780a41d95c03782" @@ -4652,6 +4845,11 @@ function-bind@^1.0.2, function-bind@^1.1.0, function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= + gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -4873,6 +5071,18 @@ glob@^7.0.5, glob@^7.1.2: once "^1.3.0" path-is-absolute "^1.0.0" +glob@^7.1.3: + version "7.1.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" + integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@~3.1.21: version "3.1.21" resolved "https://registry.yarnpkg.com/glob/-/glob-3.1.21.tgz#d29e0a055dea5138f4d07ed40e8982e83c2066cd" @@ -4910,6 +5120,11 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.8.0.tgz#c1ef45ee9bed6badf0663c5cb90e8d1adec1321d" integrity sha512-io6LkyPVuzCHBSQV9fmOwxZkUk6nIaGmxheLDgmuFv89j0fm2aqDbIXKAGfzCMHqz3HLF2Zf8WSG6VqMh2qFmA== +globals@^11.7.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + globals@^9.14.0: version "9.18.0" resolved "https://registry.yarnpkg.com/globals/-/globals-9.18.0.tgz#aa3896b3e69b487f17e31ed2143d69a8e30c2d8a" @@ -5772,7 +5987,7 @@ iconv-lite@0.4.23: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.4.4: +iconv-lite@^0.4.24, iconv-lite@^0.4.4: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -5818,6 +6033,11 @@ ignore@^3.2.0: resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.7.tgz#612289bfb3c220e186a58118618d5be8c1bab021" integrity sha512-YGG3ejvBNHRqu0559EOxxNFihD0AjpvHlC/pdGKd3X3ofe+CoJkYazwNJYTNebqpPKN+VVQbh4ZFn1DivMNuHA== +ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + image-webpack-loader@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/image-webpack-loader/-/image-webpack-loader-4.5.0.tgz#ab0da4302a58f2bf7a2eb62f166c82c6495efe8d" @@ -5906,6 +6126,14 @@ import-cwd@^2.0.0: dependencies: import-from "^2.1.0" +import-fresh@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.0.0.tgz#a3d897f420cab0e671236897f75bc14b4885c390" + integrity sha512-pOnA9tfM3Uwics+SaBLCNyZZZbK+4PTu0OPZtLlMIrv17EdBoC15S9Kn8ckJ9TZTyKb3ywNE5y1yeDxxGA7nTQ== + dependencies: + parent-module "^1.0.0" + resolve-from "^4.0.0" + import-from@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-from/-/import-from-2.1.0.tgz#335db7f2a7affd53aaa471d4b8021dee36b7f3b1" @@ -5995,6 +6223,25 @@ inquirer@^0.12.0: strip-ansi "^3.0.0" through "^2.3.6" +inquirer@^6.2.2: + version "6.4.1" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.4.1.tgz#7bd9e5ab0567cd23b41b0180b68e0cfa82fc3c0b" + integrity sha512-/Jw+qPZx4EDYsaT6uz7F4GJRNFMRdKNeUZw3ZnKV8lyuUgz/YWRCSUAJMZSVhSq4Ec0R2oYnyi6b3d4JXcL5Nw== + dependencies: + ansi-escapes "^3.2.0" + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^2.0.0" + lodash "^4.17.11" + mute-stream "0.0.7" + run-async "^2.2.0" + rxjs "^6.4.0" + string-width "^2.1.0" + strip-ansi "^5.1.0" + through "^2.3.6" + internal-ip@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/internal-ip/-/internal-ip-3.0.1.tgz#df5c99876e1d2eb2ea2d74f520e3f669a00ece27" @@ -6351,6 +6598,11 @@ is-primitive@^2.0.0: resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-2.0.0.tgz#207bab91638499c07b2adf240a41a87210034575" integrity sha1-IHurkWOEmcB7Kt8kCkGochADRXU= +is-promise@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.1.0.tgz#79a2a9ece7f096e80f36d2b2f3bc16c1ff4bf3fa" + integrity sha1-eaKp7OfwlugPNtKy87wWwf9L8/o= + is-property@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/is-property/-/is-property-1.0.2.tgz#57fe1c4e48474edd65b09911f26b1cd4095dda84" @@ -6547,7 +6799,7 @@ js-tokens@^3.0.2: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= -js-yaml@^3.12.0, js-yaml@^3.3.0, js-yaml@^3.5.1, js-yaml@^3.9.0, js-yaml@~3.13.1: +js-yaml@^3.12.0, js-yaml@^3.13.0, js-yaml@^3.3.0, js-yaml@^3.5.1, js-yaml@^3.9.0, js-yaml@~3.13.1: version "3.13.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" integrity sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw== @@ -6610,6 +6862,11 @@ json-schema-traverse@^0.4.1: resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== +json-stable-stringify-without-jsonify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" + integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= + json-stable-stringify@^1.0.0, json-stable-stringify@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz#9a759d39c5f2ff503fd5300646ed445f88c4f9af" @@ -7064,7 +7321,7 @@ lodash@^4.17.10: resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" integrity sha512-UejweD1pDoXu+AD825lWwp4ZGtSwgnpZxb3JDViD7StjQz+Nb/6l093lx4OQ0foGWNRoc19mWy7BzL+UAK2iVg== -lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.11: +lodash@^4.17.11, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.7.0, lodash@~4.17.11: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -7650,6 +7907,11 @@ mute-stream@0.0.5: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.5.tgz#8fbfabb0a98a253d3184331f9e8deb7372fac6c0" integrity sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA= +mute-stream@0.0.7: + version "0.0.7" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" + integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= + nan@^2.9.2: version "2.11.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.11.1.tgz#90e22bccb8ca57ea4cd37cc83d3819b52eea6766" @@ -8087,6 +8349,13 @@ onetime@^1.0.0: resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789" integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k= +onetime@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4" + integrity sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ= + dependencies: + mimic-fn "^1.0.0" + opn@^5.1.0: version "5.4.0" resolved "https://registry.yarnpkg.com/opn/-/opn-5.4.0.tgz#cb545e7aab78562beb11aa3bfabc7042e1761035" @@ -8195,7 +8464,7 @@ os-locale@^3.0.0: lcid "^2.0.0" mem "^4.0.0" -os-tmpdir@^1.0.0: +os-tmpdir@^1.0.0, os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= @@ -8347,6 +8616,13 @@ param-case@2.1.x: dependencies: no-case "^2.2.0" +parent-module@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" + integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== + dependencies: + callsites "^3.0.0" + parse-asn1@^5.0.0: version "5.1.1" resolved "https://registry.yarnpkg.com/parse-asn1/-/parse-asn1-5.1.1.tgz#f6bf293818332bd0dab54efb16087724745e6ca8" @@ -8439,7 +8715,7 @@ path-is-absolute@^1.0.0: resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18= -path-is-inside@^1.0.1: +path-is-inside@^1.0.1, path-is-inside@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= @@ -9001,6 +9277,11 @@ progress@^1.1.8: resolved "https://registry.yarnpkg.com/progress/-/progress-1.1.8.tgz#e260c78f6161cdd9b0e56cc3e0a85de17c7a57be" integrity sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74= +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + promise-inflight@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" @@ -9403,6 +9684,11 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" +regexpp@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" + integrity sha512-lv0M6+TkDVniA3aD1Eg0DVpfU/booSu7Eev3TDO/mZKHBfVjgCGTV4t4buppESEYDtkArYFOxTJWv6S5C+iaNw== + regexpu-core@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-1.0.0.tgz#86a763f58ee4d7c2f6b102e4764050de7ed90c6b" @@ -9579,6 +9865,11 @@ resolve-from@^3.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-3.0.0.tgz#b22c7af7d9d6881bc8b6e653335eebcb0a188748" integrity sha1-six699nWiBvItuZTM17rywoYh0g= +resolve-from@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" + integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== + resolve-pkg@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/resolve-pkg/-/resolve-pkg-0.1.0.tgz#02cc993410e2936962bd97166a1b077da9725531" @@ -9631,6 +9922,14 @@ restore-cursor@^1.0.1: exit-hook "^1.0.0" onetime "^1.0.0" +restore-cursor@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf" + integrity sha1-n37ih/gv0ybU/RYpI9YhKe7g368= + dependencies: + onetime "^2.0.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" @@ -9643,6 +9942,13 @@ right-align@^0.1.1: dependencies: align-text "^0.1.1" +rimraf@2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.6.3.tgz#b2d104fe0d8fb27cf9e0a1cda8262dd3833c6cab" + integrity sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA== + dependencies: + glob "^7.1.3" + rimraf@2.x.x, rimraf@^2.2.8, rimraf@~2.2.8: version "2.2.8" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.2.8.tgz#e439be2aaee327321952730f99a8929e4fc50582" @@ -9682,6 +9988,13 @@ run-async@^0.1.0: dependencies: once "^1.3.0" +run-async@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.3.0.tgz#0371ab4ae0bdd720d4166d7dfda64ff7a445a6c0" + integrity sha1-A3GrSuC91yDUFm19/aZP96RFpsA= + dependencies: + is-promise "^2.1.0" + run-queue@^1.0.0, run-queue@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/run-queue/-/run-queue-1.0.3.tgz#e848396f057d223f24386924618e25694161ec47" @@ -9694,6 +10007,13 @@ rx-lite@^3.1.2: resolved "https://registry.yarnpkg.com/rx-lite/-/rx-lite-3.1.2.tgz#19ce502ca572665f3b647b10939f97fd1615f102" integrity sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI= +rxjs@^6.4.0: + version "6.5.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.2.tgz#2e35ce815cd46d84d02a209fb4e5921e051dbec7" + integrity sha512-HUb7j3kvb7p7eCUHE3FqjoDsC1xfZQ4AHFWfTKSpZ+sAhhz5X1WX0ZuUqWbzB2QhSLp3DoLUG+hMdEDKqWo2Zg== + dependencies: + tslib "^1.9.0" + safe-buffer@5.1.2, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -9789,6 +10109,11 @@ semver@^5.4.1, semver@^5.6.0: resolved "https://registry.yarnpkg.com/semver/-/semver-5.6.0.tgz#7e74256fbaa49c75aa7c7a205cc22799cac80004" integrity sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg== +semver@^5.5.1: + version "5.7.0" + resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.0.tgz#790a7cf6fea5459bac96110b29b60412dc8ff96b" + integrity sha512-Ya52jSX2u7QKghxeoFGpLwCtGlt7j0oY9DYb5apt9nPlJ42ID+ulTXESnt/qAQcoSERyZ5sl3LDIOw0nAn/5DA== + send@0.13.2: version "0.13.2" resolved "https://registry.yarnpkg.com/send/-/send-0.13.2.tgz#765e7607c8055452bba6f0b052595350986036de" @@ -9975,7 +10300,7 @@ sigmund@~1.0.0: resolved "https://registry.yarnpkg.com/sigmund/-/sigmund-1.0.1.tgz#3ff21f198cad2175f9f3b781853fd94d0d19b590" integrity sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA= -signal-exit@^3.0.0: +signal-exit@^3.0.0, signal-exit@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= @@ -9990,6 +10315,15 @@ slice-ansi@0.0.4: resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35" integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU= +slice-ansi@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636" + integrity sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ== + dependencies: + ansi-styles "^3.2.0" + astral-regex "^1.0.0" + is-fullwidth-code-point "^2.0.0" + snapdragon-node@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/snapdragon-node/-/snapdragon-node-2.1.1.tgz#6c175f86ff14bdb0724563e8f3c1b021a286853b" @@ -10347,7 +10681,7 @@ string-width@^1.0.1: is-fullwidth-code-point "^1.0.0" strip-ansi "^3.0.0" -"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.1: +"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== @@ -10355,6 +10689,15 @@ string-width@^1.0.1: is-fullwidth-code-point "^2.0.0" strip-ansi "^4.0.0" +string-width@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-3.1.0.tgz#22767be21b62af1081574306f69ac51b62203961" + integrity sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w== + dependencies: + emoji-regex "^7.0.1" + is-fullwidth-code-point "^2.0.0" + strip-ansi "^5.1.0" + string_decoder@^1.0.0, string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -10393,6 +10736,13 @@ strip-ansi@^4.0.0: dependencies: ansi-regex "^3.0.0" +strip-ansi@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae" + integrity sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA== + dependencies: + ansi-regex "^4.1.0" + strip-bom-stream@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/strip-bom-stream/-/strip-bom-stream-1.0.0.tgz#e7144398577d51a6bed0fa1994fa05f43fd988ee" @@ -10457,7 +10807,7 @@ strip-json-comments@1.0.x: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-1.0.4.tgz#1e15fbcac97d3ee99bf2d73b4c656b082bbafb91" integrity sha1-HhX7ysl9Pumb8tc7TGVrCCu6+5E= -strip-json-comments@~2.0.1: +strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= @@ -10555,6 +10905,16 @@ table@^3.7.8: slice-ansi "0.0.4" string-width "^2.0.0" +table@^5.2.3: + version "5.4.1" + resolved "https://registry.yarnpkg.com/table/-/table-5.4.1.tgz#0691ae2ebe8259858efb63e550b6d5f9300171e8" + integrity sha512-E6CK1/pZe2N75rGZQotFOdmzWQ1AILtgYbMAbAjvms0S1l5IDB47zG3nCnFGB/w+7nB3vKofbLXCH7HPBo864w== + dependencies: + ajv "^6.9.1" + lodash "^4.17.11" + slice-ansi "^2.1.0" + string-width "^3.0.0" + tapable@^1.0.0, tapable@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.0.tgz#0d076a172e3d9ba088fd2272b2668fb8d194b78c" @@ -10630,7 +10990,7 @@ terser@^3.8.1: source-map "~0.6.1" source-map-support "~0.5.6" -text-table@~0.2.0: +text-table@^0.2.0, text-table@~0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ= @@ -10731,6 +11091,13 @@ tinycolor@0.x: resolved "https://registry.yarnpkg.com/tinycolor/-/tinycolor-0.0.1.tgz#320b5a52d83abb5978d81a3e887d4aefb15a6164" integrity sha1-MgtaUtg6u1l42Bo+iH1K77FaYWQ= +tmp@^0.0.33: + version "0.0.33" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" + integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== + dependencies: + os-tmpdir "~1.0.2" + to-absolute-glob@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/to-absolute-glob/-/to-absolute-glob-0.1.1.tgz#1cdfa472a9ef50c239ee66999b662ca0eb39937f" @@ -11618,6 +11985,13 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= +write@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/write/-/write-1.0.3.tgz#0800e14523b923a387e415123c865616aae0f5c3" + integrity sha512-/lg70HAjtkUgWPVZhZcm+T4hkL8Zbtp1nFNOn3lRrxnlv50SRBv7cR7RqR+GMsd3hUXy9hWBo4CHTbFTcOYwig== + dependencies: + mkdirp "^0.5.1" + write@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/write/-/write-0.2.1.tgz#5fc03828e264cea3fe91455476f7a3c566cb0757" From f67e866e7eca4a872af38b9127b9928104ed1758 Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Mon, 14 Oct 2019 15:46:33 +0200 Subject: [PATCH 05/47] feat(registry): inspect repository images (#3121) * feat(registry): inspect repository images * fix(registry): tag inspect column sorting --- app/extensions/registry-management/_module.js | 12 ++ .../registriesRepositoryTagsDatatable.html | 3 +- .../models/registryImageDetails.js | 14 ++ .../models/registryImageLayer.js | 8 + .../tag/registryRepositoryTag.html | 175 ++++++++++++++++++ .../tag/registryRepositoryTagController.js | 57 ++++++ 6 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 app/extensions/registry-management/models/registryImageDetails.js create mode 100644 app/extensions/registry-management/models/registryImageLayer.js create mode 100644 app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html create mode 100644 app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js diff --git a/app/extensions/registry-management/_module.js b/app/extensions/registry-management/_module.js index 9fdde0c5d..777ebcc55 100644 --- a/app/extensions/registry-management/_module.js +++ b/app/extensions/registry-management/_module.js @@ -34,8 +34,20 @@ angular.module('portainer.extensions.registrymanagement', []) } } }; + var registryRepositoryTag = { + name: 'portainer.registries.registry.repository.tag', + url: '/:tag', + views: { + 'content@': { + templateUrl: './views/repositories/tag/registryRepositoryTag.html', + controller: 'RegistryRepositoryTagController', + controllerAs: 'ctrl' + } + } + }; $stateRegistryProvider.register(registryConfiguration); $stateRegistryProvider.register(registryRepositories); $stateRegistryProvider.register(registryRepositoryTags); + $stateRegistryProvider.register(registryRepositoryTag); }]); diff --git a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html index 6e524d77e..15448a9e4 100644 --- a/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html +++ b/app/extensions/registry-management/components/registries-repository-tags-datatable/registriesRepositoryTagsDatatable.html @@ -44,7 +44,8 @@ - {{ item.Name }} + {{ item.Name }} {{ item.Os }}/{{ item.Architecture }} {{ item.ImageId | trimshasum }} diff --git a/app/extensions/registry-management/models/registryImageDetails.js b/app/extensions/registry-management/models/registryImageDetails.js new file mode 100644 index 000000000..9a80adedc --- /dev/null +++ b/app/extensions/registry-management/models/registryImageDetails.js @@ -0,0 +1,14 @@ +export function RegistryImageDetailsViewModel(data) { + this.Id = data.id; + this.Parent = data.parent; + this.Created = data.created; + this.DockerVersion = data.docker_version; + this.Os = data.os; + this.Architecture = data.architecture; + this.Author = data.author; + this.Command = data.config.Cmd; + this.Entrypoint = data.container_config.Entrypoint ? data.container_config.Entrypoint : ''; + this.ExposedPorts = data.container_config.ExposedPorts ? Object.keys(data.container_config.ExposedPorts) : []; + this.Volumes = data.container_config.Volumes ? Object.keys(data.container_config.Volumes) : []; + this.Env = data.container_config.Env ? data.container_config.Env : []; +} diff --git a/app/extensions/registry-management/models/registryImageLayer.js b/app/extensions/registry-management/models/registryImageLayer.js new file mode 100644 index 000000000..f8990a514 --- /dev/null +++ b/app/extensions/registry-management/models/registryImageLayer.js @@ -0,0 +1,8 @@ +import _ from 'lodash-es'; + +export function RegistryImageLayerViewModel(order, data) { + this.Order = order; + this.Id = data.id; + this.Created = data.created; + this.CreatedBy = _.join(data.container_config.Cmd, ' '); +} \ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html new file mode 100644 index 000000000..69316f24e --- /dev/null +++ b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTag.html @@ -0,0 +1,175 @@ + + + + + + + + Registries > + {{ ctrl.registry.Name }} > + {{ ctrl.context.repository }} > + {{ ctrl.context.tag }} + + + +
+
+ + + +
+
+
+
+
+ {{ tag }} +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
ID + {{ ctrl.details.Id }} +
Parent{{ ctrl.details.Parent }}
Created{{ ctrl.details.Created|getisodate }}
BuildDocker {{ ctrl.details.DockerVersion }} on {{ ctrl.details.Os}}, {{ ctrl.details.Architecture }}
Author{{ ctrl.details.Author }}
+
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
CMD{{ ctrl.details.Command|command }}
ENTRYPOINT{{ ctrl.details.Entrypoint|command }}
EXPOSE + + {{ port }} + +
VOLUME + + {{ volume }} + +
ENV + + + + + +
{{ var|key: '=' }}{{ var|value: '=' }}
+
+
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + +
+ + Order + + + + + + Layer + + + +
+ {{ layer.Order }} + +
+ + + {{ layer.CreatedBy | truncate:130 }} + + + + + + +
+
+ + {{ layer.CreatedBy }} + +
+
+
+
+
+
\ No newline at end of file diff --git a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js new file mode 100644 index 000000000..4f34b89f1 --- /dev/null +++ b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js @@ -0,0 +1,57 @@ +import _ from 'lodash-es'; +import angular from 'angular'; +import { RegistryImageLayerViewModel } from 'Extensions/registry-management/models/registryImageLayer'; +import { RegistryImageDetailsViewModel } from 'Extensions/registry-management/models/registryImageDetails'; + +class RegistryRepositoryTagController { + + /* @ngInject */ + constructor($transition$, $async, Notifications, RegistryService, RegistryV2Service, imagelayercommandFilter) { + this.$transition$ = $transition$; + this.$async = $async; + this.Notifications = Notifications; + this.RegistryService = RegistryService; + this.RegistryV2Service = RegistryV2Service; + this.imagelayercommandFilter = imagelayercommandFilter; + + this.context = {}; + this.onInit = this.onInit.bind(this); + } + + toggleLayerCommand(layerId) { + $('#layer-command-expander'+layerId+' span').toggleClass('glyphicon-plus-sign glyphicon-minus-sign'); + $('#layer-command-'+layerId+'-short').toggle(); + $('#layer-command-'+layerId+'-full').toggle(); + } + + order(sortType) { + this.Sort.Reverse = (this.Sort.Type === sortType) ? !this.Sort.Reverse : false; + this.Sort.Type = sortType; + } + + async onInit() { + this.context.registryId = this.$transition$.params().id; + this.context.repository = this.$transition$.params().repository; + this.context.tag = this.$transition$.params().tag; + this.Sort = { + Type: 'Order', + Reverse: false + } + try { + this.registry = await this.RegistryService.registry(this.context.registryId); + this.tag = await this.RegistryV2Service.tag(this.context.registryId, this.context.repository, this.context.tag); + const length = this.tag.History.length; + this.history = _.map(this.tag.History, (layer, idx) => new RegistryImageLayerViewModel(length - idx, layer)); + _.forEach(this.history, (item) => item.CreatedBy = this.imagelayercommandFilter(item.CreatedBy)) + this.details = new RegistryImageDetailsViewModel(this.tag.History[0]); + } catch (error) { + this.Notifications.error('Failure', error, 'Unable to retrieve tag') + } + } + + $onInit() { + return this.$async(this.onInit); + } +} +export default RegistryRepositoryTagController; +angular.module('portainer.extensions.registrymanagement').controller('RegistryRepositoryTagController', RegistryRepositoryTagController); From accca0f2a6ba30b7986a929db83962d1f67dfe3f Mon Sep 17 00:00:00 2001 From: Mattias Edlund Date: Tue, 15 Oct 2019 18:13:57 +0900 Subject: [PATCH 06/47] feat(containers): added support for port range mappings when deploying containers (#3194) * feat(containers): added support for port range mappings when deploying containers * feat(containers): added placeholders to port publishing input fields * feat(containers): added a tooltip to the manual network port publishing * feat(containers): improved the code consistency --- app/docker/helpers/containerHelper.js | 180 ++++++++++++++++++ .../create/createContainerController.js | 48 +---- .../containers/create/createcontainer.html | 9 +- 3 files changed, 195 insertions(+), 42 deletions(-) diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js index 759162b72..107115f21 100644 --- a/app/docker/helpers/containerHelper.js +++ b/app/docker/helpers/containerHelper.js @@ -1,5 +1,66 @@ +import _ from 'lodash-es'; import splitargs from 'splitargs/src/splitargs' +const portPattern = /^([1-9]|[1-5]?[0-9]{2,4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$/m; + +function parsePort(port) { + if (portPattern.test(port)) { + return parseInt(port); + } else { + return 0; + } +} + +function parsePortRange(portRange) { + if (typeof portRange !== 'string') { + portRange = portRange.toString(); + } + + // Split the range and convert to integers + const stringPorts = _.split(portRange, '-', 2); + const intPorts = _.map(stringPorts, parsePort); + + // If it's not a range, we still make sure that we return two ports (start & end) + if (intPorts.length == 1) { + intPorts.push(intPorts[0]); + } + + return intPorts; +} + +function isValidPortRange(portRange) { + if (typeof portRange === 'string') { + portRange = parsePortRange(); + } + + return Array.isArray(portRange) && portRange.length === 2 && + portRange[0] > 0 && portRange[1] >= portRange[0]; +} + +function createPortRange(portRangeText, port) { + if (typeof portRangeText !== 'string') { + portRangeText = portRangeText.toString(); + } + + let hostIp = null; + const colonIndex = portRangeText.indexOf(':'); + if (colonIndex >= 0) { + hostIp = portRangeText.substr(0, colonIndex); + portRangeText = portRangeText.substr(colonIndex + 1); + } + + port = (typeof port === 'number' ? port : parsePort(port)); + const portRange = parsePortRange(portRangeText); + const startPort = Math.min(portRange[0], port); + const endPort = Math.max(portRange[1], port); + + if (hostIp) { + return hostIp + ':' + startPort + '-' + endPort; + } else { + return startPort + '-' + endPort; + } +} + angular.module('portainer.docker') .factory('ContainerHelper', [function ContainerHelperFactory() { 'use strict'; @@ -54,5 +115,124 @@ angular.module('portainer.docker') return config; }; + helper.preparePortBindings = function(portBindings) { + const bindings = {}; + _.forEach(portBindings, (portBinding) => { + if (!portBinding.containerPort) { + return; + } + + let hostPort = portBinding.hostPort; + const containerPortRange = parsePortRange(portBinding.containerPort); + if (!isValidPortRange(containerPortRange)) { + throw new Error('Invalid port specification: ' + portBinding.containerPort); + } + + const startPort = containerPortRange[0]; + const endPort = containerPortRange[1]; + let hostIp = undefined; + let startHostPort = 0; + let endHostPort = 0; + if (hostPort) { + if (hostPort.indexOf(':') > -1) { + const hostAndPort = _.split(hostPort, ':'); + hostIp = hostAndPort[0]; + hostPort = hostAndPort[1]; + } + + const hostPortRange = parsePortRange(hostPort); + if (!isValidPortRange(hostPortRange)) { + throw new Error('Invalid port specification: ' + hostPort); + } + + startHostPort = hostPortRange[0]; + endHostPort = hostPortRange[1]; + if (endPort !== startPort && (endPort - startPort) !== (endHostPort - startHostPort)) { + throw new Error('Invalid port specification: ' + hostPort); + } + } + + for (let i = 0; i <= (endPort - startPort); i++) { + const containerPort = (startPort + i).toString(); + if (startHostPort > 0) { + hostPort = (startHostPort + i).toString(); + } + if (startPort === endPort && startHostPort !== endHostPort) { + hostPort += '-' + endHostPort.toString(); + } + + const bindKey = containerPort + '/' + portBinding.protocol; + bindings[bindKey] = [{ HostIp: hostIp, HostPort: hostPort }]; + } + }); + return bindings; + }; + + helper.sortAndCombinePorts = function(portBindings) { + const bindings = []; + const portBindingKeys = _.keys(portBindings); + + // Group the port bindings by protocol + const portBindingKeysByProtocol = _.groupBy(portBindingKeys, (portKey) => { + return _.split(portKey, '/')[1]; + }); + + _.forEach(portBindingKeysByProtocol, (portBindingKeys, protocol) => { + // Group the port bindings by host IP + const portBindingKeysByHostIp = _.groupBy(portBindingKeys, (portKey) => { + const portBinding = portBindings[portKey][0]; + return portBinding.HostIp || ''; + }); + + _.forEach(portBindingKeysByHostIp, (portBindingKeys) => { + // Sort by host port + const sortedPortBindingKeys = _.orderBy(portBindingKeys, (portKey) => { + return parseInt(_.split(portKey, '/')[0]); + }); + + let previousHostPort = -1; + let previousContainerPort = -1; + _.forEach(sortedPortBindingKeys, (portKey) => { + const portKeySplit = _.split(portKey, '/'); + const containerPort = parseInt(portKeySplit[0]); + const portBinding = portBindings[portKey][0]; + const hostPort = parsePort(portBinding.HostPort); + + // We only combine single ports, and skip the host port ranges on one container port + if (hostPort > 0) { + // If we detect consecutive ports, we create a range of them + if (bindings.length > 0 && previousHostPort === (hostPort - 1) && previousContainerPort === (containerPort - 1)) { + bindings[bindings.length-1].hostPort = createPortRange(bindings[bindings.length-1].hostPort, hostPort); + bindings[bindings.length-1].containerPort = createPortRange(bindings[bindings.length-1].containerPort, containerPort); + previousHostPort = hostPort; + previousContainerPort = containerPort; + return; + } + + previousHostPort = hostPort; + previousContainerPort = containerPort; + } else { + previousHostPort = -1; + previousContainerPort = -1; + } + + let bindingHostPort = portBinding.HostPort.toString(); + if (portBinding.HostIp) { + bindingHostPort = portBinding.HostIp + ':' + bindingHostPort; + } + + const binding = { + hostPort: bindingHostPort, + containerPort: containerPort, + protocol: protocol + }; + bindings.push(binding); + }); + }); + }); + + return bindings; + }; + return helper; }]); diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index 73fb8e17b..fe41674a6 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -5,8 +5,8 @@ import { ContainerDetailsViewModel } from '../../../models/container'; angular.module('portainer.docker') -.controller('CreateContainerController', ['$q', '$scope', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', -function ($q, $scope, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { +.controller('CreateContainerController', ['$q', '$scope', '$async', '$state', '$timeout', '$transition$', '$filter', 'Container', 'ContainerHelper', 'Image', 'ImageHelper', 'Volume', 'NetworkService', 'ResourceControlService', 'Authentication', 'Notifications', 'ContainerService', 'ImageService', 'FormValidator', 'ModalService', 'RegistryService', 'SystemService', 'SettingsService', 'PluginService', 'HttpRequestHelper', +function ($q, $scope, $async, $state, $timeout, $transition$, $filter, Container, ContainerHelper, Image, ImageHelper, Volume, NetworkService, ResourceControlService, Authentication, Notifications, ContainerService, ImageService, FormValidator, ModalService, RegistryService, SystemService, SettingsService, PluginService, HttpRequestHelper) { $scope.create = create; @@ -138,25 +138,8 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function preparePortBindings(config) { - var bindings = {}; - if (config.ExposedPorts === undefined) { - config.ExposedPorts = {}; - } - config.HostConfig.PortBindings.forEach(function (portBinding) { - if (portBinding.containerPort) { - var key = portBinding.containerPort + '/' + portBinding.protocol; - var binding = {}; - if (portBinding.hostPort && portBinding.hostPort.indexOf(':') > -1) { - var hostAndPort = portBinding.hostPort.split(':'); - binding.HostIp = hostAndPort[0]; - binding.HostPort = hostAndPort[1]; - } else { - binding.HostPort = portBinding.hostPort; - } - bindings[key] = [binding]; - config.ExposedPorts[key] = {}; - } - }); + const bindings = ContainerHelper.preparePortBindings(config.HostConfig.PortBindings); + _.forEach(bindings, (_, key) => config.ExposedPorts[key] = {}); config.HostConfig.PortBindings = bindings; } @@ -330,22 +313,7 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function loadFromContainerPortBindings() { - var bindings = []; - for (var p in $scope.config.HostConfig.PortBindings) { - if ({}.hasOwnProperty.call($scope.config.HostConfig.PortBindings, p)) { - var hostPort = ''; - if ($scope.config.HostConfig.PortBindings[p][0].HostIp) { - hostPort = $scope.config.HostConfig.PortBindings[p][0].HostIp + ':'; - } - hostPort += $scope.config.HostConfig.PortBindings[p][0].HostPort; - var b = { - 'hostPort': hostPort, - 'containerPort': p.split('/')[0], - 'protocol': p.split('/')[1] - }; - bindings.push(b); - } - } + const bindings = ContainerHelper.sortAndCombinePorts($scope.config.HostConfig.PortBindings); $scope.config.HostConfig.PortBindings = bindings; } @@ -784,8 +752,10 @@ function ($q, $scope, $state, $timeout, $transition$, $filter, Container, Contai } function createNewContainer() { - var config = prepareConfiguration(); - return ContainerService.createAndStartContainer(config); + return $async(async () => { + const config = prepareConfiguration(); + return await ContainerService.createAndStartContainer(config); + }); } function applyResourceControl(newContainer) { diff --git a/app/docker/views/containers/create/createcontainer.html b/app/docker/views/containers/create/createcontainer.html index a2d688d84..881053077 100644 --- a/app/docker/views/containers/create/createcontainer.html +++ b/app/docker/views/containers/create/createcontainer.html @@ -68,7 +68,10 @@
- + publish a new network port @@ -79,7 +82,7 @@
host - +
@@ -88,7 +91,7 @@
container - +
From 53942b741ae51fe28c2f86376c03e3d33118841a Mon Sep 17 00:00:00 2001 From: Aaron Korte Date: Thu, 24 Oct 2019 00:38:41 +0200 Subject: [PATCH 07/47] fix(api): increment stack identifier atomically (#3290) --- api/bolt/internal/db.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/api/bolt/internal/db.go b/api/bolt/internal/db.go index 8cbb1d2d0..9689fa691 100644 --- a/api/bolt/internal/db.go +++ b/api/bolt/internal/db.go @@ -82,13 +82,15 @@ func DeleteObject(db *bolt.DB, bucketName string, key []byte) error { func GetNextIdentifier(db *bolt.DB, bucketName string) int { var identifier int - db.View(func(tx *bolt.Tx) error { + db.Update(func(tx *bolt.Tx) error { bucket := tx.Bucket([]byte(bucketName)) - id := bucket.Sequence() + id, err := bucket.NextSequence() + if err != nil { + return err + } identifier = int(id) return nil }) - identifier++ return identifier } From 542b76912a02e6392435268378dcfcc6f3deb941 Mon Sep 17 00:00:00 2001 From: William Date: Fri, 25 Oct 2019 03:36:24 +1300 Subject: [PATCH 08/47] feat(endpoint-details): add edge-key to commands (#3302) --- app/portainer/views/endpoints/edit/endpoint.html | 11 ++++++++--- .../views/endpoints/edit/endpointController.js | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/portainer/views/endpoints/edit/endpoint.html b/app/portainer/views/endpoints/edit/endpoint.html index b2a1d400a..c8792066e 100644 --- a/app/portainer/views/endpoints/edit/endpoint.html +++ b/app/portainer/views/endpoints/edit/endpoint.html @@ -28,7 +28,10 @@

- Deploy the Edge agent on your remote Docker environment using the following command + Deploy the Edge agent on your remote Docker environment using the following command(s) +

+

+ The agent will communicate with Portainer via {{ edgeKeyDetails.instanceURL }} and tcp://{{ edgeKeyDetails.tunnelServerAddr }}

@@ -40,6 +43,7 @@ --restart always \ -e EDGE=1 \ -e EDGE_ID={{ randomEdgeID }} \ + -e EDGE_KEY={{ endpoint.EdgeKey }} \ -e CAP_HOST_MANAGEMENT=1 \ -p 8000:80 \ -v portainer_agent_data:/data \ @@ -59,6 +63,7 @@ -e AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent \ -e EDGE=1 \ -e EDGE_ID={{ randomEdgeID }} \ + -e EDGE_KEY={{ endpoint.EdgeKey }} \ -e CAP_HOST_MANAGEMENT=1 \ --mode global \ --publish mode=host,published=8000,target=80 \ @@ -83,10 +88,10 @@

- Use the following join token to associate the Edge agent with this endpoint + For those prestaging the edge agent, use the following join token to associate the Edge agent with this endpoint.

- The agent will communicate with Portainer via {{ edgeKeyDetails.instanceURL }} and tcp://{{ edgeKeyDetails.tunnelServerAddr }} + You can read more about pre-staging in the userguide available here.

diff --git a/app/portainer/views/endpoints/edit/endpointController.js b/app/portainer/views/endpoints/edit/endpointController.js index 9447d66b4..0309c197f 100644 --- a/app/portainer/views/endpoints/edit/endpointController.js +++ b/app/portainer/views/endpoints/edit/endpointController.js @@ -22,9 +22,9 @@ function ($q, $scope, $state, $transition$, $filter, clipboard, EndpointService, $scope.copyEdgeAgentDeploymentCommand = function() { if ($scope.state.deploymentTab === 0) { - clipboard.copyText('docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/docker/volumes:/var/lib/docker/volumes -v /:/host --restart always -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID +' -e CAP_HOST_MANAGEMENT=1 -p 8000:80 -v portainer_agent_data:/data --name portainer_edge_agent portainer/agent'); + clipboard.copyText('docker run -d -v /var/run/docker.sock:/var/run/docker.sock -v /var/lib/docker/volumes:/var/lib/docker/volumes -v /:/host --restart always -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID + ' -e EDGE_KEY=' + $scope.endpoint.EdgeKey +' -e CAP_HOST_MANAGEMENT=1 -p 8000:80 -v portainer_agent_data:/data --name portainer_edge_agent portainer/agent'); } else { - clipboard.copyText('docker network create --driver overlay portainer_agent_network; docker service create --name portainer_edge_agent --network portainer_agent_network -e AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID +' -e CAP_HOST_MANAGEMENT=1 --mode global --publish mode=host,published=8000,target=80 --constraint \'node.platform.os == linux\' --mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock --mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volume --mount type=bind,src=//,dst=/host --mount type=volume,src=portainer_agent_data,dst=/data portainer/agent'); + clipboard.copyText('docker network create --driver overlay portainer_agent_network; docker service create --name portainer_edge_agent --network portainer_agent_network -e AGENT_CLUSTER_ADDR=tasks.portainer_edge_agent -e EDGE=1 -e EDGE_ID=' + $scope.randomEdgeID + ' -e EDGE_KEY=' + $scope.endpoint.EdgeKey +' -e CAP_HOST_MANAGEMENT=1 --mode global --publish mode=host,published=8000,target=80 --constraint \'node.platform.os == linux\' --mount type=bind,src=//var/run/docker.sock,dst=/var/run/docker.sock --mount type=bind,src=//var/lib/docker/volumes,dst=/var/lib/docker/volume --mount type=bind,src=//,dst=/host --mount type=volume,src=portainer_agent_data,dst=/data portainer/agent'); } $('#copyNotificationDeploymentCommand').show().fadeOut(2500); }; From 91c83eccd2af17c48b8f5fba195ea900a6fe5fd5 Mon Sep 17 00:00:00 2001 From: William Date: Fri, 25 Oct 2019 18:53:29 +1300 Subject: [PATCH 09/47] feat(project): add automated testing with cypress (#3305) * feat(project): add automated testing with cypress * feat(project): made suggested edits * feat(project): add init test * feat(project): add socket to correct container --- test/e2e/.env | 1 + test/e2e/cypress.json | 3 +++ test/e2e/cypress/integration/spec.js | 21 +++++++++++++++++++++ test/e2e/cypress/plugins/index.js | 17 +++++++++++++++++ test/e2e/cypress/support/commands.js | 25 +++++++++++++++++++++++++ test/e2e/cypress/support/index.js | 20 ++++++++++++++++++++ test/e2e/docker-compose.yml | 19 +++++++++++++++++++ 7 files changed, 106 insertions(+) create mode 100644 test/e2e/.env create mode 100644 test/e2e/cypress.json create mode 100644 test/e2e/cypress/integration/spec.js create mode 100644 test/e2e/cypress/plugins/index.js create mode 100644 test/e2e/cypress/support/commands.js create mode 100644 test/e2e/cypress/support/index.js create mode 100644 test/e2e/docker-compose.yml diff --git a/test/e2e/.env b/test/e2e/.env new file mode 100644 index 000000000..95d0b0cb8 --- /dev/null +++ b/test/e2e/.env @@ -0,0 +1 @@ +PORTAINER_TAG=develop \ No newline at end of file diff --git a/test/e2e/cypress.json b/test/e2e/cypress.json new file mode 100644 index 000000000..5e0725b20 --- /dev/null +++ b/test/e2e/cypress.json @@ -0,0 +1,3 @@ +{ + "video": false +} \ No newline at end of file diff --git a/test/e2e/cypress/integration/spec.js b/test/e2e/cypress/integration/spec.js new file mode 100644 index 000000000..cf2b5a1f9 --- /dev/null +++ b/test/e2e/cypress/integration/spec.js @@ -0,0 +1,21 @@ +// Tests to run +context('Tests to run', () => { + //Browse to homepage before each test + beforeEach(() => { + cy.visit('/') + }) + describe('Init admin', function() { + it('Create user and verify success', function() { + cy.get('#username') + .should('have.value', 'admin') + cy.get('#password') + .type('portaineriscool') + .should('have.value', 'portaineriscool') + cy.get('#confirm_password') + .type('portaineriscool') + .should('have.value', 'portaineriscool') + cy.get('[type=submit]').click() + cy.url().should('include', '/init/endpoint') + }) + }) +}) diff --git a/test/e2e/cypress/plugins/index.js b/test/e2e/cypress/plugins/index.js new file mode 100644 index 000000000..fd170fba6 --- /dev/null +++ b/test/e2e/cypress/plugins/index.js @@ -0,0 +1,17 @@ +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/test/e2e/cypress/support/commands.js b/test/e2e/cypress/support/commands.js new file mode 100644 index 000000000..c1f5a772e --- /dev/null +++ b/test/e2e/cypress/support/commands.js @@ -0,0 +1,25 @@ +// *********************************************** +// This example commands.js shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add("login", (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This is will overwrite an existing command -- +// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) diff --git a/test/e2e/cypress/support/index.js b/test/e2e/cypress/support/index.js new file mode 100644 index 000000000..d68db96df --- /dev/null +++ b/test/e2e/cypress/support/index.js @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml new file mode 100644 index 000000000..43a349ded --- /dev/null +++ b/test/e2e/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' +services: + portainer: + image: portainerci/portainer:$PORTAINER_TAG + container_name: e2e-portainer + volumes: + - /var/run/docker.sock:/var/run/docker.sock + + cypress: + image: cypress/included:3.4.1 + container_name: e2e-cypress + depends_on: + - portainer + working_dir: /app + environment: + - CYPRESS_baseUrl=http://e2e-portainer:9000 + volumes: + - ./cypress:/app/cypress + - ./cypress.json:/app/cypress.json \ No newline at end of file From dbef3a0508cce6598f9464c5e40f336b43dbacd4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 28 Oct 2019 15:29:32 +1300 Subject: [PATCH 10/47] feat(test): update cypress projectId --- test/e2e/cypress.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/e2e/cypress.json b/test/e2e/cypress.json index 5e0725b20..30549e1b6 100644 --- a/test/e2e/cypress.json +++ b/test/e2e/cypress.json @@ -1,3 +1,4 @@ { - "video": false -} \ No newline at end of file + "projectId": "cgz5j5", + "video": false +} From c6e9d8e61603e78afe47acd1870c2d94f33cd5f4 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Mon, 28 Oct 2019 16:51:59 +1300 Subject: [PATCH 11/47] feat(test): update docker-compose file for cypress e2e testing --- test/e2e/docker-compose.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml index 43a349ded..b0953671a 100644 --- a/test/e2e/docker-compose.yml +++ b/test/e2e/docker-compose.yml @@ -7,13 +7,13 @@ services: - /var/run/docker.sock:/var/run/docker.sock cypress: - image: cypress/included:3.4.1 + image: cypress/included:3.5.0 container_name: e2e-cypress depends_on: - portainer - working_dir: /app + working_dir: /e2e environment: - CYPRESS_baseUrl=http://e2e-portainer:9000 volumes: - - ./cypress:/app/cypress - - ./cypress.json:/app/cypress.json \ No newline at end of file + - ./cypress:/e2e/cypress + - ./cypress.json:/e2e/cypress.json From 36de0aee7bc8fd7f3d742dab6317e11cf14de81f Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 29 Oct 2019 11:38:38 +1300 Subject: [PATCH 12/47] feat(test): update e2e setup --- test/e2e/docker-compose.yml | 49 ++++++++++++++++++++++++++++++++++++- test/e2e/run-e2e.sh | 23 +++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100755 test/e2e/run-e2e.sh diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml index b0953671a..89b878a3c 100644 --- a/test/e2e/docker-compose.yml +++ b/test/e2e/docker-compose.yml @@ -5,15 +5,62 @@ services: container_name: e2e-portainer volumes: - /var/run/docker.sock:/var/run/docker.sock + networks: + - e2e-ci cypress: image: cypress/included:3.5.0 container_name: e2e-cypress + # command: --record --browser chrome depends_on: - portainer working_dir: /e2e environment: - - CYPRESS_baseUrl=http://e2e-portainer:9000 + CYPRESS_baseUrl: http://e2e-portainer:9000 + # CYPRESS_RECORD_KEY: volumes: - ./cypress:/e2e/cypress - ./cypress.json:/e2e/cypress.json + networks: + - e2e-ci + + manager1: + image: docker:dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: + hostname: manager1 + networks: + - e2e-ci + depends_on: + - manager2 + - worker1 + - worker2 + manager2: + image: docker:dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: + hostname: manager2 + networks: + - e2e-ci + worker1: + image: docker:dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: + hostname: worker1 + networks: + - e2e-ci + worker2: + image: docker:dind + privileged: true + environment: + DOCKER_TLS_CERTDIR: + hostname: worker2 + networks: + - e2e-ci + +networks: + e2e-ci: + driver: bridge diff --git a/test/e2e/run-e2e.sh b/test/e2e/run-e2e.sh new file mode 100755 index 000000000..7d017c0fd --- /dev/null +++ b/test/e2e/run-e2e.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +exec_in() { docker-compose exec -T $@; } + +# Up all dinds nodes +docker-compose up -d manager1 manager2 worker1 worker2 + +# Manager1 init +exec_in manager1 docker swarm init +TOKEN_WORKER="$(exec_in manager1 docker swarm join-token -q worker)" +TOKEN_MANAGER="$(exec_in manager1 docker swarm join-token -q manager)" + +# Manager2 join +exec_in manager2 docker swarm join --token $TOKEN_MANAGER manager1:2377 + +# Worker1 join +exec_in worker1 docker swarm join --token $TOKEN_WORKER manager1:2377 + +# Worker2 join +exec_in worker2 docker swarm join --token $TOKEN_WORKER manager1:2377 + +# Up portainer +docker-compose up --exit-code-from cypress From 07db1ca16ebbccfcdfc7e197484f1dd6061f9801 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Tue, 29 Oct 2019 12:51:26 +1300 Subject: [PATCH 13/47] feat(test): update e2e to support swarm and CI mode --- test/e2e/docker-compose.ci.yml | 17 +++++++++++++++++ test/e2e/docker-compose.yml | 3 +-- test/e2e/run-e2e.sh | 10 ++++++++-- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 test/e2e/docker-compose.ci.yml diff --git a/test/e2e/docker-compose.ci.yml b/test/e2e/docker-compose.ci.yml new file mode 100644 index 000000000..03bcd48e6 --- /dev/null +++ b/test/e2e/docker-compose.ci.yml @@ -0,0 +1,17 @@ +version: '3' +services: + cypress: + image: cypress/included:3.5.0 + container_name: e2e-cypress + command: --record --browser chrome + depends_on: + - portainer + working_dir: /e2e + environment: + CYPRESS_baseUrl: http://e2e-portainer:9000 + CYPRESS_RECORD_KEY: ${CYPRESS_RECORD_KEY} + volumes: + - ./cypress:/e2e/cypress + - ./cypress.json:/e2e/cypress.json + networks: + - e2e-ci diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml index 89b878a3c..8f36c7a8d 100644 --- a/test/e2e/docker-compose.yml +++ b/test/e2e/docker-compose.yml @@ -11,13 +11,12 @@ services: cypress: image: cypress/included:3.5.0 container_name: e2e-cypress - # command: --record --browser chrome + command: --browser chrome depends_on: - portainer working_dir: /e2e environment: CYPRESS_baseUrl: http://e2e-portainer:9000 - # CYPRESS_RECORD_KEY: volumes: - ./cypress:/e2e/cypress - ./cypress.json:/e2e/cypress.json diff --git a/test/e2e/run-e2e.sh b/test/e2e/run-e2e.sh index 7d017c0fd..37fc85ac6 100755 --- a/test/e2e/run-e2e.sh +++ b/test/e2e/run-e2e.sh @@ -19,5 +19,11 @@ exec_in worker1 docker swarm join --token $TOKEN_WORKER manager1:2377 # Worker2 join exec_in worker2 docker swarm join --token $TOKEN_WORKER manager1:2377 -# Up portainer -docker-compose up --exit-code-from cypress +# Run portainer + cypress +# Use export CI=1 to run in CI mode +if [ -z "${CI}" ]; +then + docker-compose up --exit-code-from cypress +else + docker-compose -f docker-compose.yml -f docker-compose.ci.yml up --exit-code-from cypress +fi From 310b6b34da90621b471161a451a8e21afce590c6 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 31 Oct 2019 08:46:50 +1300 Subject: [PATCH 14/47] fix(api): update user authorizations after team deletion (#3315) --- api/authorizations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/authorizations.go b/api/authorizations.go index e2101bdae..5fb3f1b77 100644 --- a/api/authorizations.go +++ b/api/authorizations.go @@ -164,7 +164,7 @@ func (service *AuthorizationService) RemoveTeamAccessPolicies(teamID TeamID) err } } - return nil + return service.UpdateUsersAuthorizations() } // RemoveUserAccessPolicies will remove all existing access policies associated to the specified user From 01754901617a8ad9108a1d63008dd51d3b5c2f64 Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Thu, 31 Oct 2019 12:12:04 +1300 Subject: [PATCH 15/47] fix(api): data migration to update default Portainer authorizations (#3314) --- api/bolt/migrator/migrate_dbversion20.go | 22 ++++++++++++++++++++++ api/bolt/migrator/migrator.go | 8 ++++++++ api/portainer.go | 2 +- 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 api/bolt/migrator/migrate_dbversion20.go diff --git a/api/bolt/migrator/migrate_dbversion20.go b/api/bolt/migrator/migrate_dbversion20.go new file mode 100644 index 000000000..09b642855 --- /dev/null +++ b/api/bolt/migrator/migrate_dbversion20.go @@ -0,0 +1,22 @@ +package migrator + +import ( + portainer "github.com/portainer/portainer/api" +) + +func (m *Migrator) updateUsersToDBVersion21() error { + legacyUsers, err := m.userService.Users() + if err != nil { + return err + } + + for _, user := range legacyUsers { + user.PortainerAuthorizations = portainer.DefaultPortainerAuthorizations() + err = m.userService.UpdateUser(user.ID, &user) + if err != nil { + return err + } + } + + return nil +} diff --git a/api/bolt/migrator/migrator.go b/api/bolt/migrator/migrator.go index 048ab11ac..fa012355c 100644 --- a/api/bolt/migrator/migrator.go +++ b/api/bolt/migrator/migrator.go @@ -287,5 +287,13 @@ func (m *Migrator) Migrate() error { } } + // Portainer next + if m.currentDBVersion < 21 { + err := m.updateUsersToDBVersion21() + if err != nil { + return err + } + } + return m.versionService.StoreDBVersion(portainer.DBVersion) } diff --git a/api/portainer.go b/api/portainer.go index d7997b2d7..aac60aad4 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -905,7 +905,7 @@ const ( // APIVersion is the version number of the Portainer API APIVersion = "1.22.1" // DBVersion is the version number of the Portainer database - DBVersion = 20 + DBVersion = 21 // AssetsServerURL represents the URL of the Portainer asset server AssetsServerURL = "https://portainer-io-assets.sfo2.digitaloceanspaces.com" // MessageOfTheDayURL represents the URL where Portainer MOTD message can be retrieved From c559b6b55cb14d8b11b485abad3ffff58e47e6cb Mon Sep 17 00:00:00 2001 From: George Cheng Date: Fri, 1 Nov 2019 11:15:33 +0800 Subject: [PATCH 16/47] fix(container-creation): Fix bad env in container creation (#2996) Currently we are using RegExp `/\=(.+)/` to catch key-value of environment variables, which could not match empty-value environment variables such as `KEY=`. This commit will change the RegExp to `/\=(.*)/`, which matches the empty values. --- app/docker/views/containers/create/createContainerController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/docker/views/containers/create/createContainerController.js b/app/docker/views/containers/create/createContainerController.js index fe41674a6..e8e48c083 100644 --- a/app/docker/views/containers/create/createContainerController.js +++ b/app/docker/views/containers/create/createContainerController.js @@ -393,7 +393,7 @@ function ($q, $scope, $async, $state, $timeout, $transition$, $filter, Container var envArr = []; for (var e in $scope.config.Env) { if ({}.hasOwnProperty.call($scope.config.Env, e)) { - var arr = $scope.config.Env[e].split(/\=(.+)/); + var arr = $scope.config.Env[e].split(/\=(.*)/); envArr.push({'name': arr[0], 'value': arr[1]}); } } From 03d9d6afbb0a149e44782d7a3dbd89cc19bd21ff Mon Sep 17 00:00:00 2001 From: Anthony Lapenna Date: Fri, 1 Nov 2019 17:46:53 +1300 Subject: [PATCH 17/47] Revert "fix(api): fix invalid resource control check (#3225)" (#3327) This reverts commit 1fbe6a12f1bd6ad1a74190efa76f31681c43ce4d. --- api/http/proxy/docker_transport.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/http/proxy/docker_transport.go b/api/http/proxy/docker_transport.go index 8abb21ce3..81515ce5a 100644 --- a/api/http/proxy/docker_transport.go +++ b/api/http/proxy/docker_transport.go @@ -380,7 +380,7 @@ func (p *proxyTransport) restrictedOperation(request *http.Request, resourceID s } resourceControl := getResourceControlByResourceID(resourceID, resourceControls) - if resourceControl == nil || !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { + if resourceControl != nil && !canUserAccessResource(tokenData.ID, userTeamIDs, resourceControl) { return writeAccessDeniedResponse() } } From 198e92c734580f0b43aec15944f0de5042f5898b Mon Sep 17 00:00:00 2001 From: xAt0mZ Date: Tue, 12 Nov 2019 04:28:31 +0100 Subject: [PATCH 18/47] feat(registry): gitlab support (#3107) * feat(api): gitlab registry type * feat(registries): early support for gitlab registries * feat(app): registry service selector * feat(registry): gitlab support : list repositories and tags - remove features missing * feat(registry): gitlab registry remove features * feat(registry): gitlab switch to registry V2 API for repositories and tags * feat(api): use development extension binary * fix(registry): avoid 401 on gitlab retrieve to disconnect the user * feat(registry): gitlab browse projects without extension * style(app): code cleaning * refactor(app): PR review changes + refactor on types * fix(gitlab): remove gitlab info from registrymanagementconfig and force gitlab type * style(api): go fmt * feat(api): update APIVersion and ExtensionDefinitionsURL * fix(api): fix invalid RM extension URL * feat(registry): PAT scope help * feat(registry): defaults on registry creation * style(registry-creation): update layout and text for Gitlab registry * feat(registry-creation): update gitlab notice --- api/http/handler/registries/handler.go | 5 +- api/http/handler/registries/proxy.go | 2 +- api/http/handler/registries/proxy_gitlab.go | 23 ++ .../registries/proxy_management_gitlab.go | 66 +++++ .../handler/registries/registry_create.go | 18 +- api/http/proxy/gitlab.go | 38 +++ api/http/proxy/manager.go | 5 + api/portainer.go | 13 +- app/app.js | 2 +- app/docker/helpers/containerHelper.js | 2 +- .../registryRepositoriesDatatable.html | 2 +- .../helpers/localRegistryHelper.js | 7 +- .../models/gitlabRegistry.js | 8 + .../models/registryRepository.js | 7 +- .../models/registryTypes.js | 6 + .../models/repositoryTag.js | 10 +- .../registry-management/rest/gitlab.js | 33 +++ .../rest/transform/gitlabResponseGetLink.js | 10 + .../services/registryGitlabService.js | 86 ++++++ .../services/registryServiceSelector.js | 82 ++++++ .../services/registryV2Service.js | 247 ++++++++++++------ .../configure/configureRegistryController.js | 8 +- .../views/configure/configureregistry.html | 4 +- .../repositories/edit/registryRepository.html | 4 + .../edit/registryRepositoryController.js | 26 +- .../registryRepositoriesController.js | 20 +- .../tag/registryRepositoryTagController.js | 6 +- .../registry-form-azure.html | 2 +- .../registry-form-custom.html | 2 +- .../gitlabProjectsDatatable.html | 94 +++++++ .../gitlabProjectsDatatable.js | 13 + .../gitlabProjectsDatatableController.js | 48 ++++ .../registry-form-gitlab.html | 139 ++++++++++ .../registry-form-gitlab.js | 12 + app/portainer/models/registry.js | 20 +- app/portainer/services/api/registryService.js | 14 + .../create/createRegistryController.js | 55 +++- .../registries/create/createregistry.html | 43 ++- 38 files changed, 1022 insertions(+), 160 deletions(-) create mode 100644 api/http/handler/registries/proxy_gitlab.go create mode 100644 api/http/handler/registries/proxy_management_gitlab.go create mode 100644 api/http/proxy/gitlab.go create mode 100644 app/extensions/registry-management/models/gitlabRegistry.js create mode 100644 app/extensions/registry-management/models/registryTypes.js create mode 100644 app/extensions/registry-management/rest/gitlab.js create mode 100644 app/extensions/registry-management/rest/transform/gitlabResponseGetLink.js create mode 100644 app/extensions/registry-management/services/registryGitlabService.js create mode 100644 app/extensions/registry-management/services/registryServiceSelector.js create mode 100644 app/portainer/components/forms/registry-form-gitlab/gitlab-projects-datatable/gitlabProjectsDatatable.html create mode 100644 app/portainer/components/forms/registry-form-gitlab/gitlab-projects-datatable/gitlabProjectsDatatable.js create mode 100644 app/portainer/components/forms/registry-form-gitlab/gitlab-projects-datatable/gitlabProjectsDatatableController.js create mode 100644 app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.html create mode 100644 app/portainer/components/forms/registry-form-gitlab/registry-form-gitlab.js diff --git a/api/http/handler/registries/handler.go b/api/http/handler/registries/handler.go index 202a81fdc..3b2646dcb 100644 --- a/api/http/handler/registries/handler.go +++ b/api/http/handler/registries/handler.go @@ -46,6 +46,9 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler { bouncer.AdminAccess(httperror.LoggerHandler(h.registryDelete))).Methods(http.MethodDelete) h.PathPrefix("/registries/{id}/v2").Handler( bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.proxyRequestsToRegistryAPI))) - + h.PathPrefix("/registries/{id}/proxies/gitlab").Handler( + bouncer.RestrictedAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithRegistry))) + h.PathPrefix("/registries/proxies/gitlab").Handler( + bouncer.AdminAccess(httperror.LoggerHandler(h.proxyRequestsToGitlabAPIWithoutRegistry))) return h } diff --git a/api/http/handler/registries/proxy.go b/api/http/handler/registries/proxy.go index d54520508..3f94bed4a 100644 --- a/api/http/handler/registries/proxy.go +++ b/api/http/handler/registries/proxy.go @@ -41,7 +41,7 @@ func (handler *Handler) proxyRequestsToRegistryAPI(w http.ResponseWriter, r *htt if proxy == nil { proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to register registry proxy", err} + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} } } diff --git a/api/http/handler/registries/proxy_gitlab.go b/api/http/handler/registries/proxy_gitlab.go new file mode 100644 index 000000000..47b5f4169 --- /dev/null +++ b/api/http/handler/registries/proxy_gitlab.go @@ -0,0 +1,23 @@ +package registries + +import ( + "net/http" + + httperror "github.com/portainer/libhttp/error" +) + +// request on /api/registries/proxies/gitlab +func (handler *Handler) proxyRequestsToGitlabAPIWithoutRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + domain := r.Header.Get("X-Gitlab-Domain") + if domain == "" { + return &httperror.HandlerError{http.StatusBadRequest, "No Gitlab domain provided", nil} + } + + proxy, err := handler.ProxyManager.CreateGitlabProxy(domain) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create gitlab proxy", err} + } + + http.StripPrefix("/registries/proxies/gitlab", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/registries/proxy_management_gitlab.go b/api/http/handler/registries/proxy_management_gitlab.go new file mode 100644 index 000000000..28f1ead12 --- /dev/null +++ b/api/http/handler/registries/proxy_management_gitlab.go @@ -0,0 +1,66 @@ +package registries + +import ( + "encoding/json" + "net/http" + "strconv" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + "github.com/portainer/portainer/api" +) + +// request on /api/registries/{id}/proxies/gitlab +func (handler *Handler) proxyRequestsToGitlabAPIWithRegistry(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { + registryID, err := request.RetrieveNumericRouteVariableValue(r, "id") + if err != nil { + return &httperror.HandlerError{http.StatusBadRequest, "Invalid registry identifier route variable", err} + } + + registry, err := handler.RegistryService.Registry(portainer.RegistryID(registryID)) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Unable to find a registry with the specified identifier inside the database", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a registry with the specified identifier inside the database", err} + } + + err = handler.requestBouncer.RegistryAccess(r, registry) + if err != nil { + return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access registry", portainer.ErrEndpointAccessDenied} + } + + extension, err := handler.ExtensionService.Extension(portainer.RegistryManagementExtension) + if err == portainer.ErrObjectNotFound { + return &httperror.HandlerError{http.StatusNotFound, "Registry management extension is not enabled", err} + } else if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a extension with the specified identifier inside the database", err} + } + + var proxy http.Handler + proxy = handler.ProxyManager.GetExtensionProxy(portainer.RegistryManagementExtension) + if proxy == nil { + proxy, err = handler.ProxyManager.CreateExtensionProxy(portainer.RegistryManagementExtension) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create extension proxy for registry manager", err} + } + } + + config := &portainer.RegistryManagementConfiguration{ + Type: portainer.GitlabRegistry, + Password: registry.Password, + } + + encodedConfiguration, err := json.Marshal(config) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to encode management configuration", err} + } + + id := strconv.Itoa(int(registryID)) + r.Header.Set("X-RegistryManagement-Key", id+"-gitlab") + r.Header.Set("X-RegistryManagement-URI", registry.Gitlab.InstanceURL) + r.Header.Set("X-RegistryManagement-Config", string(encodedConfiguration)) + r.Header.Set("X-PortainerExtension-License", extension.License.LicenseKey) + + http.StripPrefix("/registries/"+id+"/proxies/gitlab", proxy).ServeHTTP(w, r) + return nil +} diff --git a/api/http/handler/registries/registry_create.go b/api/http/handler/registries/registry_create.go index 1b6dbf638..09f6d0a2e 100644 --- a/api/http/handler/registries/registry_create.go +++ b/api/http/handler/registries/registry_create.go @@ -12,11 +12,12 @@ import ( type registryCreatePayload struct { Name string - Type int + Type portainer.RegistryType URL string Authentication bool Username string Password string + Gitlab portainer.GitlabRegistryData } func (payload *registryCreatePayload) Validate(r *http.Request) error { @@ -29,8 +30,8 @@ func (payload *registryCreatePayload) Validate(r *http.Request) error { if payload.Authentication && (govalidator.IsNull(payload.Username) || govalidator.IsNull(payload.Password)) { return portainer.Error("Invalid credentials. Username and password must be specified when authentication is enabled") } - if payload.Type != 1 && payload.Type != 2 && payload.Type != 3 { - return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry) or 3 (custom registry)") + if payload.Type != portainer.QuayRegistry && payload.Type != portainer.AzureRegistry && payload.Type != portainer.CustomRegistry && payload.Type != portainer.GitlabRegistry { + return portainer.Error("Invalid registry type. Valid values are: 1 (Quay.io), 2 (Azure container registry), 3 (custom registry) or 4 (Gitlab registry)") } return nil } @@ -42,16 +43,6 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} } - registries, err := handler.RegistryService.Registries() - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve registries from the database", err} - } - for _, r := range registries { - if r.URL == payload.URL { - return &httperror.HandlerError{http.StatusConflict, "A registry with the same URL already exists", portainer.ErrRegistryAlreadyExists} - } - } - registry := &portainer.Registry{ Type: portainer.RegistryType(payload.Type), Name: payload.Name, @@ -61,6 +52,7 @@ func (handler *Handler) registryCreate(w http.ResponseWriter, r *http.Request) * Password: payload.Password, UserAccessPolicies: portainer.UserAccessPolicies{}, TeamAccessPolicies: portainer.TeamAccessPolicies{}, + Gitlab: payload.Gitlab, } err = handler.RegistryService.CreateRegistry(registry) diff --git a/api/http/proxy/gitlab.go b/api/http/proxy/gitlab.go new file mode 100644 index 000000000..b809a09a3 --- /dev/null +++ b/api/http/proxy/gitlab.go @@ -0,0 +1,38 @@ +package proxy + +import ( + "errors" + "net/http" + "net/url" +) + +type gitlabTransport struct { + httpTransport *http.Transport +} + +func newGitlabProxy(uri string) (http.Handler, error) { + url, err := url.Parse(uri) + if err != nil { + return nil, err + } + + proxy := newSingleHostReverseProxyWithHostHeader(url) + proxy.Transport = &gitlabTransport{ + httpTransport: &http.Transport{}, + } + + return proxy, nil +} + +func (transport *gitlabTransport) RoundTrip(request *http.Request) (*http.Response, error) { + token := request.Header.Get("Private-Token") + if token == "" { + return nil, errors.New("No gitlab token provided") + } + r, err := http.NewRequest(request.Method, request.URL.String(), nil) + if err != nil { + return nil, err + } + r.Header.Set("Private-Token", token) + return transport.httpTransport.RoundTrip(r) +} diff --git a/api/http/proxy/manager.go b/api/http/proxy/manager.go index 7a1f38580..aa4a68bef 100644 --- a/api/http/proxy/manager.go +++ b/api/http/proxy/manager.go @@ -182,3 +182,8 @@ func (manager *Manager) createProxy(endpoint *portainer.Endpoint) (http.Handler, return manager.createDockerProxy(endpoint) } + +// CreateGitlabProxy creates a new HTTP reverse proxy that can be used to send requests to the Gitlab API.. +func (manager *Manager) CreateGitlabProxy(url string) (http.Handler, error) { + return newGitlabProxy(url) +} diff --git a/api/portainer.go b/api/portainer.go index aac60aad4..078fde6ce 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -191,6 +191,12 @@ type ( // RegistryType represents a type of registry RegistryType int + // GitlabRegistryData represents data required for gitlab registry to work + GitlabRegistryData struct { + ProjectID int `json:"ProjectId"` + InstanceURL string `json:"InstanceURL"` + } + // Registry represents a Docker registry with all the info required // to connect to it Registry struct { @@ -202,6 +208,7 @@ type ( Username string `json:"Username"` Password string `json:"Password,omitempty"` ManagementConfiguration *RegistryManagementConfiguration `json:"ManagementConfiguration"` + Gitlab GitlabRegistryData `json:"Gitlab"` UserAccessPolicies UserAccessPolicies `json:"UserAccessPolicies"` TeamAccessPolicies TeamAccessPolicies `json:"TeamAccessPolicies"` @@ -903,7 +910,7 @@ type ( const ( // APIVersion is the version number of the Portainer API - APIVersion = "1.22.1" + APIVersion = "1.23.0-dev" // DBVersion is the version number of the Portainer database DBVersion = 21 // AssetsServerURL represents the URL of the Portainer asset server @@ -913,7 +920,7 @@ const ( // VersionCheckURL represents the URL used to retrieve the latest version of Portainer VersionCheckURL = "https://api.github.com/repos/portainer/portainer/releases/latest" // ExtensionDefinitionsURL represents the URL where Portainer extension definitions can be retrieved - ExtensionDefinitionsURL = AssetsServerURL + "/extensions-1.22.1.json" + ExtensionDefinitionsURL = AssetsServerURL + "/extensions-" + APIVersion + ".json" // SupportProductsURL represents the URL where Portainer support products can be retrieved SupportProductsURL = AssetsServerURL + "/support.json" // PortainerAgentHeader represents the name of the header available in any agent response @@ -1076,6 +1083,8 @@ const ( AzureRegistry // CustomRegistry represents a custom registry CustomRegistry + // GitlabRegistry represents a gitlab registry + GitlabRegistry ) const ( diff --git a/app/app.js b/app/app.js index db35643a2..7b3419893 100644 --- a/app/app.js +++ b/app/app.js @@ -68,7 +68,7 @@ function initAuthentication(authManager, Authentication, $rootScope, $state) { // authManager.redirectWhenUnauthenticated() + unauthenticatedRedirector // to have more controls on which URL should trigger the unauthenticated state. $rootScope.$on('unauthenticated', function (event, data) { - if (!_.includes(data.config.url, '/v2/')) { + if (!_.includes(data.config.url, '/v2/') && !_.includes(data.config.url, '/api/v4/')) { $state.go('portainer.auth', { error: 'Your session has expired' }); } }); diff --git a/app/docker/helpers/containerHelper.js b/app/docker/helpers/containerHelper.js index 107115f21..ab169a4c5 100644 --- a/app/docker/helpers/containerHelper.js +++ b/app/docker/helpers/containerHelper.js @@ -121,7 +121,7 @@ angular.module('portainer.docker') if (!portBinding.containerPort) { return; } - + let hostPort = portBinding.hostPort; const containerPortRange = parsePortRange(portBinding.containerPort); if (!isValidPortRange(containerPortRange)) { diff --git a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html index b076acffd..88c085a3d 100644 --- a/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html +++ b/app/extensions/registry-management/components/registries-repositories-datatable/registryRepositoriesDatatable.html @@ -30,7 +30,7 @@ - {{ item.Name }} {{ item.TagsCount }} diff --git a/app/extensions/registry-management/helpers/localRegistryHelper.js b/app/extensions/registry-management/helpers/localRegistryHelper.js index 5e74f09ab..f68073f41 100644 --- a/app/extensions/registry-management/helpers/localRegistryHelper.js +++ b/app/extensions/registry-management/helpers/localRegistryHelper.js @@ -1,3 +1,4 @@ +import _ from 'lodash-es'; import { RepositoryTagViewModel } from '../models/repositoryTag'; angular.module('portainer.extensions.registrymanagement') @@ -7,7 +8,7 @@ angular.module('portainer.extensions.registrymanagement') var helper = {}; function historyRawToParsed(rawHistory) { - return angular.fromJson(rawHistory[0].v1Compatibility); + return _.map(rawHistory, (item) => angular.fromJson(item.v1Compatibility)); } helper.manifestsToTag = function (manifests) { @@ -16,7 +17,7 @@ angular.module('portainer.extensions.registrymanagement') var history = historyRawToParsed(v1.history); var name = v1.tag; - var os = history.os; + var os = history[0].os; var arch = v1.architecture; var size = v2.layers.reduce(function (a, b) { return { @@ -26,7 +27,7 @@ angular.module('portainer.extensions.registrymanagement') var imageId = v2.config.digest; var imageDigest = v2.digest; - return new RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2); + return new RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2, history); }; return helper; diff --git a/app/extensions/registry-management/models/gitlabRegistry.js b/app/extensions/registry-management/models/gitlabRegistry.js new file mode 100644 index 000000000..7a824873d --- /dev/null +++ b/app/extensions/registry-management/models/gitlabRegistry.js @@ -0,0 +1,8 @@ +export function RegistryGitlabProject(project) { + this.Id = project.id; + this.Description = project.description; + this.Name = project.name; + this.Namespace = project.namespace ? project.namespace.name : ''; + this.PathWithNamespace = project.path_with_namespace; + this.RegistryEnabled = project.container_registry_enabled; +} diff --git a/app/extensions/registry-management/models/registryRepository.js b/app/extensions/registry-management/models/registryRepository.js index bc509225a..1d7353af1 100644 --- a/app/extensions/registry-management/models/registryRepository.js +++ b/app/extensions/registry-management/models/registryRepository.js @@ -1,5 +1,5 @@ import _ from 'lodash-es'; -export default function RegistryRepositoryViewModel(item) { +export function RegistryRepositoryViewModel(item) { if (item.name && item.tags) { this.Name = item.name; this.TagsCount = _.without(item.tags, null).length; @@ -7,4 +7,9 @@ export default function RegistryRepositoryViewModel(item) { this.Name = item; this.TagsCount = 0; } +} + +export function RegistryRepositoryGitlabViewModel(data) { + this.Name = data.path; + this.TagsCount = data.tags.length; } \ No newline at end of file diff --git a/app/extensions/registry-management/models/registryTypes.js b/app/extensions/registry-management/models/registryTypes.js new file mode 100644 index 000000000..2c218f339 --- /dev/null +++ b/app/extensions/registry-management/models/registryTypes.js @@ -0,0 +1,6 @@ +export const RegistryTypes = Object.freeze({ + 'QUAY': 1, + 'AZURE': 2, + 'CUSTOM': 3, + 'GITLAB': 4 +}) \ No newline at end of file diff --git a/app/extensions/registry-management/models/repositoryTag.js b/app/extensions/registry-management/models/repositoryTag.js index 37d7b7cc2..19ffd6965 100644 --- a/app/extensions/registry-management/models/repositoryTag.js +++ b/app/extensions/registry-management/models/repositoryTag.js @@ -1,4 +1,4 @@ -export function RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2) { +export function RepositoryTagViewModel(name, os, arch, size, imageDigest, imageId, v2, history) { this.Name = name; this.Os = os || ''; this.Architecture = arch || ''; @@ -6,6 +6,7 @@ export function RepositoryTagViewModel(name, os, arch, size, imageDigest, imageI this.ImageDigest = imageDigest || ''; this.ImageId = imageId || ''; this.ManifestV2 = v2 || {}; + this.History = history || []; } export function RepositoryShortTag(name, imageId, imageDigest, manifest) { @@ -13,4 +14,9 @@ export function RepositoryShortTag(name, imageId, imageDigest, manifest) { this.ImageId = imageId; this.ImageDigest = imageDigest; this.ManifestV2 = manifest; -} \ No newline at end of file +} + +export function RepositoryAddTagPayload(tag, manifest) { + this.Tag = tag; + this.Manifest = manifest; +} diff --git a/app/extensions/registry-management/rest/gitlab.js b/app/extensions/registry-management/rest/gitlab.js new file mode 100644 index 000000000..cb69219aa --- /dev/null +++ b/app/extensions/registry-management/rest/gitlab.js @@ -0,0 +1,33 @@ +import gitlabResponseGetLink from './transform/gitlabResponseGetLink' + +angular.module('portainer.extensions.registrymanagement') +.factory('Gitlab', ['$resource', 'API_ENDPOINT_REGISTRIES', +function GitlabFactory($resource, API_ENDPOINT_REGISTRIES) { + 'use strict'; + return function(env) { + const headers = {}; + if (env) { + headers['Private-Token'] = env.token; + headers['X-Gitlab-Domain'] = env.url + } + + const baseUrl = API_ENDPOINT_REGISTRIES + '/:id/proxies/gitlab/api/v4/projects'; + + return $resource(baseUrl, {id:'@id'}, + { + projects: { + method: 'GET', + params: { membership: 'true' }, + transformResponse: gitlabResponseGetLink, + headers: headers + }, + repositories :{ + method: 'GET', + url: baseUrl + '/:projectId/registry/repositories', + params: { tags: true }, + headers: headers, + transformResponse: gitlabResponseGetLink + } + }); + }; +}]); diff --git a/app/extensions/registry-management/rest/transform/gitlabResponseGetLink.js b/app/extensions/registry-management/rest/transform/gitlabResponseGetLink.js new file mode 100644 index 000000000..759bbd681 --- /dev/null +++ b/app/extensions/registry-management/rest/transform/gitlabResponseGetLink.js @@ -0,0 +1,10 @@ +export default function gitlabResponseGetLink(data, headers) { + let response = {}; + try { + response.data = angular.fromJson(data); + response.next = headers('X-Next-Page'); + } catch (error) { + response = data; + } + return response; +} \ No newline at end of file diff --git a/app/extensions/registry-management/services/registryGitlabService.js b/app/extensions/registry-management/services/registryGitlabService.js new file mode 100644 index 000000000..b405e889e --- /dev/null +++ b/app/extensions/registry-management/services/registryGitlabService.js @@ -0,0 +1,86 @@ +import _ from 'lodash-es'; +import { RegistryGitlabProject } from '../models/gitlabRegistry'; +import { RegistryRepositoryGitlabViewModel } from '../models/registryRepository'; + +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryGitlabService', ['$async', 'Gitlab', +function RegistryGitlabServiceFactory($async, Gitlab) { + 'use strict'; + var service = {}; + + /** + * PROJECTS + */ + + async function _getProjectsPage(env, params, projects) { + const response = await Gitlab(env).projects(params).$promise; + projects = _.concat(projects, response.data); + if (response.next) { + params.page = response.next; + projects = await _getProjectsPage(env, params, projects); + } + return projects; + } + + async function projectsAsync(url, token) { + try { + const data = await _getProjectsPage({url: url, token: token}, {page: 1}, []); + return _.map(data, (project) => new RegistryGitlabProject(project)); + } catch (error) { + throw {msg: 'Unable to retrieve projects', err: error}; + } + } + + /** + * END PROJECTS + */ + + /** + * REPOSITORIES + */ + + async function _getRepositoriesPage(params, repositories) { + const response = await Gitlab().repositories(params).$promise; + repositories = _.concat(repositories, response.data); + if (response.next) { + params.page = response.next; + repositories = await _getRepositoriesPage(params, repositories); + } + return repositories; + } + + async function repositoriesAsync(registry) { + try { + const params = { + id: registry.Id, + projectId: registry.Gitlab.ProjectId, + page: 1 + }; + const data = await _getRepositoriesPage(params, []); + return _.map(data, (r) => new RegistryRepositoryGitlabViewModel(r)); + } catch (error) { + throw {msg: 'Unable to retrieve repositories', err: error}; + } + } + + /** + * END REPOSITORIES + */ + + /** + * SERVICE FUNCTIONS DECLARATION + */ + + function projects(url, token) { + return $async(projectsAsync, url, token); + } + + function repositories(registry) { + return $async(repositoriesAsync, registry); + } + + service.projects = projects; + service.repositories = repositories; + return service; +} +]); diff --git a/app/extensions/registry-management/services/registryServiceSelector.js b/app/extensions/registry-management/services/registryServiceSelector.js new file mode 100644 index 000000000..f8b155188 --- /dev/null +++ b/app/extensions/registry-management/services/registryServiceSelector.js @@ -0,0 +1,82 @@ +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; + +angular.module('portainer.extensions.registrymanagement') +.factory('RegistryServiceSelector', ['$q', 'RegistryV2Service', 'RegistryGitlabService', +function RegistryServiceSelector($q, RegistryV2Service, RegistryGitlabService) { + 'use strict'; + const service = {}; + + service.ping = ping; + service.repositories = repositories; + service.getRepositoriesDetails = getRepositoriesDetails; + service.tags = tags; + service.getTagsDetails = getTagsDetails; + service.tag = tag; + service.addTag = addTag; + service.deleteManifest = deleteManifest; + + service.shortTagsWithProgress = shortTagsWithProgress; + service.deleteTagsWithProgress = deleteTagsWithProgress; + service.retagWithProgress = retagWithProgress; + + function ping(registry, forceNewConfig) { + let service = RegistryV2Service; + return service.ping(registry, forceNewConfig) + } + + function repositories(registry) { + let service = RegistryV2Service; + if (registry.Type === RegistryTypes.GITLAB) { + service = RegistryGitlabService; + } + return service.repositories(registry); + } + + function getRepositoriesDetails(registry, repositories) { + let service = RegistryV2Service; + return service.getRepositoriesDetails(registry, repositories); + } + + function tags(registry, repository) { + let service = RegistryV2Service; + return service.tags(registry, repository); + } + + function getTagsDetails(registry, repository, tags) { + let service = RegistryV2Service; + return service.getTagsDetails(registry, repository, tags); + } + + function tag(registry, repository, tag) { + let service = RegistryV2Service; + return service.tag(registry, repository, tag); + } + + function addTag(registry, repository, tag, manifest) { + let service = RegistryV2Service; + return service.addTag(registry, repository, tag, manifest); + } + + function deleteManifest(registry, repository, digest) { + let service = RegistryV2Service; + return service.deleteManifest(registry, repository, digest); + } + + function shortTagsWithProgress(registry, repository, tagsList) { + let service = RegistryV2Service; + return service.shortTagsWithProgress(registry, repository, tagsList); + } + + function deleteTagsWithProgress(registry, repository, modifiedDigests, impactedTags) { + let service = RegistryV2Service; + return service.deleteTagsWithProgress(registry, repository, modifiedDigests, impactedTags); + } + + function retagWithProgress(registry, repository, modifiedTags, modifiedDigests, impactedTags) { + let service = RegistryV2Service; + return service.retagWithProgress(registry, repository, modifiedTags, modifiedDigests, impactedTags); + } + + return service; +} +]); diff --git a/app/extensions/registry-management/services/registryV2Service.js b/app/extensions/registry-management/services/registryV2Service.js index f0094f6d6..b0ba0bc11 100644 --- a/app/extensions/registry-management/services/registryV2Service.js +++ b/app/extensions/registry-management/services/registryV2Service.js @@ -1,6 +1,7 @@ import _ from 'lodash-es'; import { RepositoryShortTag } from '../models/repositoryTag'; -import RegistryRepositoryViewModel from '../models/registryRepository'; +import { RepositoryAddTagPayload } from '../models/repositoryTag' +import { RegistryRepositoryViewModel } from '../models/registryRepository'; import genericAsyncGenerator from './genericAsyncGenerator'; angular.module('portainer.extensions.registrymanagement') @@ -9,12 +10,24 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg 'use strict'; var service = {}; - service.ping = function(id, forceNewConfig) { + /** + * PING + */ + function ping(registry, forceNewConfig) { + const id = registry.Id; if (forceNewConfig) { return RegistryCatalog.pingWithForceNew({ id: id }).$promise; } return RegistryCatalog.ping({ id: id }).$promise; - }; + } + + /** + * END PING + */ + + /** + * REPOSITORIES + */ function _getCatalogPage(params, deferred, repositories) { RegistryCatalog.get(params).$promise.then(function(data) { @@ -27,7 +40,7 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg }); } - function getCatalog(id) { + function _getCatalog(id) { var deferred = $q.defer(); var repositories = []; @@ -35,13 +48,12 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg return deferred.promise; } - service.catalog = function (id) { - var deferred = $q.defer(); + function repositories(registry) { + const deferred = $q.defer(); + const id = registry.Id; - getCatalog(id).then(function success(data) { - var repositories = data.map(function (repositoryName) { - return new RegistryRepositoryViewModel(repositoryName); - }); + _getCatalog(id).then(function success(data) { + const repositories = _.map(data, (repositoryName) => new RegistryRepositoryViewModel(repositoryName)); deferred.resolve(repositories); }) .catch(function error(err) { @@ -52,14 +64,37 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg }); return deferred.promise; - }; + } - service.tags = function (id, repository) { - var deferred = $q.defer(); + function getRepositoriesDetails(registry, repositories) { + const deferred = $q.defer(); + const promises = _.map(repositories, (repository) => tags(registry, repository.Name)); + + $q.all(promises) + .then(function success(data) { + var repositories = data.map(function (item) { + return new RegistryRepositoryViewModel(item); + }); + repositories = _.without(repositories, undefined); + deferred.resolve(repositories); + }) + .catch(function error(err) { + deferred.reject({ + msg: 'Unable to retrieve repositories', + err: err + }); + }); - _getTagsPage({id: id, repository: repository}, deferred, {tags:[]}); return deferred.promise; - }; + } + + /** + * END REPOSITORIES + */ + + /** + * TAGS + */ function _getTagsPage(params, deferred, previousTags) { RegistryTags.get(params).$promise.then(function(data) { @@ -78,45 +113,23 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg }); } - service.getRepositoriesDetails = function (id, repositories) { - var deferred = $q.defer(); - var promises = []; - for (var i = 0; i < repositories.length; i++) { - var repository = repositories[i].Name; - promises.push(service.tags(id, repository)); - } - - $q.all(promises) - .then(function success(data) { - var repositories = data.map(function (item) { - return new RegistryRepositoryViewModel(item); - }); - repositories = _.without(repositories, undefined); - deferred.resolve(repositories); - }) - .catch(function error(err) { - deferred.reject({ - msg: 'Unable to retrieve repositories', - err: err - }); - }); + function tags(registry, repository) { + const deferred = $q.defer(); + const id = registry.Id; + _getTagsPage({id: id, repository: repository}, deferred, {tags:[]}); return deferred.promise; - }; + } - service.getTagsDetails = function (id, repository, tags) { - var promises = []; - - for (var i = 0; i < tags.length; i++) { - var tag = tags[i].Name; - promises.push(service.tag(id, repository, tag)); - } + function getTagsDetails(registry, repository, tags) { + const promises = _.map(tags, (t) => tag(registry, repository, t.Name)); return $q.all(promises); - }; + } - service.tag = function (id, repository, tag) { - var deferred = $q.defer(); + function tag(registry, repository, tag) { + const deferred = $q.defer(); + const id = registry.Id; var promises = { v1: RegistryManifestsJquery.get({ @@ -142,35 +155,33 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg }); return deferred.promise; - }; + } - service.addTag = function (id, repository, {tag, manifest}) { + /** + * END TAGS + */ + + /** + * ADD TAG + */ + + // tag: RepositoryAddTagPayload + function _addTagFromGenerator(registry, repository, tag) { + return addTag(registry, repository, tag.Tag, tag.Manifest); + } + + function addTag(registry, repository, tag, manifest) { + const id = registry.Id; delete manifest.digest; return RegistryManifestsJquery.put({ id: id, repository: repository, tag: tag }, manifest); - }; + } - service.deleteManifest = function (id, repository, imageDigest) { - return RegistryManifestsJquery.delete({ - id: id, - repository: repository, - tag: imageDigest - }); - }; - - service.shortTag = function(id, repository, tag) { - return new Promise ((resolve, reject) => { - RegistryManifestsJquery.getV2({id:id, repository: repository, tag: tag}) - .then((data) => resolve(new RepositoryShortTag(tag, data.config.digest, data.digest, data))) - .catch((err) => reject(err)) - }); - }; - - async function* addTagsWithProgress(id, repository, tagsList, progression = 0) { - for await (const partialResult of genericAsyncGenerator($q, tagsList, service.addTag, [id, repository])) { + async function* _addTagsWithProgress(registry, repository, tagsList, progression = 0) { + for await (const partialResult of genericAsyncGenerator($q, tagsList, _addTagFromGenerator, [registry, repository])) { if (typeof partialResult === 'number') { yield progression + partialResult; } else { @@ -179,36 +190,110 @@ function RegistryV2ServiceFactory($q, $async, RegistryCatalog, RegistryTags, Reg } } - service.shortTagsWithProgress = async function* (id, repository, tagsList) { - yield* genericAsyncGenerator($q, tagsList, service.shortTag, [id, repository]); + /** + * END ADD TAG + */ + + /** + * DELETE MANIFEST + */ + + function deleteManifest(registry, repository, imageDigest) { + const id = registry.Id; + return RegistryManifestsJquery.delete({ + id: id, + repository: repository, + tag: imageDigest + }); } - async function* deleteManifestsWithProgress(id, repository, manifests) { - for await (const partialResult of genericAsyncGenerator($q, manifests, service.deleteManifest, [id, repository])) { + async function* _deleteManifestsWithProgress(registry, repository, manifests) { + for await (const partialResult of genericAsyncGenerator($q, manifests, deleteManifest, [registry, repository])) { yield partialResult; } } - service.retagWithProgress = async function* (id, repository, modifiedTags, modifiedDigests, impactedTags){ - yield* deleteManifestsWithProgress(id, repository, modifiedDigests); + /** + * END DELETE MANIFEST + */ + + /** + * SHORT TAG + */ + + function _shortTagFromGenerator(id, repository, tag) { + return new Promise ((resolve, reject) => { + RegistryManifestsJquery.getV2({id:id, repository: repository, tag: tag}) + .then((data) => resolve(new RepositoryShortTag(tag, data.config.digest, data.digest, data))) + .catch((err) => reject(err)) + }); + } + + async function* shortTagsWithProgress(registry, repository, tagsList) { + const id = registry.Id; + yield* genericAsyncGenerator($q, tagsList, _shortTagFromGenerator, [id, repository]); + } + + /** + * END SHORT TAG + */ + + /** + * RETAG + */ + async function* retagWithProgress(registry, repository, modifiedTags, modifiedDigests, impactedTags){ + yield* _deleteManifestsWithProgress(registry, repository, modifiedDigests); const newTags = _.map(impactedTags, (item) => { const tagFromTable = _.find(modifiedTags, { 'Name': item.Name }); const name = tagFromTable && tagFromTable.Name !== tagFromTable.NewName ? tagFromTable.NewName : item.Name; - return { tag: name, manifest: item.ManifestV2 }; + return new RepositoryAddTagPayload(name, item.ManifestV2); }); - yield* addTagsWithProgress(id, repository, newTags, modifiedDigests.length); + yield* _addTagsWithProgress(registry, repository, newTags, modifiedDigests.length); } - service.deleteTagsWithProgress = async function* (id, repository, modifiedDigests, impactedTags) { - yield* deleteManifestsWithProgress(id, repository, modifiedDigests); + /** + * END RETAG + */ - const newTags = _.map(impactedTags, (item) => {return {tag: item.Name, manifest: item.ManifestV2}}) + /** + * DELETE TAGS + */ - yield* addTagsWithProgress(id, repository, newTags, modifiedDigests.length); + async function* deleteTagsWithProgress(registry, repository, modifiedDigests, impactedTags) { + yield* _deleteManifestsWithProgress(registry, repository, modifiedDigests); + + const newTags = _.map(impactedTags, (item) => new RepositoryAddTagPayload(item.Name, item.ManifestV2)); + + yield* _addTagsWithProgress(registry, repository, newTags, modifiedDigests.length); } + /** + * END DELETE TAGS + */ + + /** + * SERVICE FUNCTIONS DECLARATION + */ + + service.ping = ping; + + service.repositories = repositories; + service.getRepositoriesDetails = getRepositoriesDetails; + + service.tags = tags; + service.tag = tag; + service.getTagsDetails = getTagsDetails; + + service.shortTagsWithProgress = shortTagsWithProgress; + + service.addTag = addTag; + service.deleteManifest = deleteManifest; + + service.deleteTagsWithProgress = deleteTagsWithProgress; + service.retagWithProgress = retagWithProgress; + return service; } ]); diff --git a/app/extensions/registry-management/views/configure/configureRegistryController.js b/app/extensions/registry-management/views/configure/configureRegistryController.js index 75311e01a..cd6d33a1d 100644 --- a/app/extensions/registry-management/views/configure/configureRegistryController.js +++ b/app/extensions/registry-management/views/configure/configureRegistryController.js @@ -1,8 +1,9 @@ import { RegistryManagementConfigurationDefaultModel } from '../../../../portainer/models/registry'; +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; angular.module('portainer.extensions.registrymanagement') -.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryV2Service', 'Notifications', -function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Notifications) { +.controller('ConfigureRegistryController', ['$scope', '$state', '$transition$', 'RegistryService', 'RegistryServiceSelector', 'Notifications', +function ($scope, $state, $transition$, RegistryService, RegistryServiceSelector, Notifications) { $scope.state = { testInProgress: false, @@ -18,7 +19,7 @@ function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Noti RegistryService.configureRegistry($scope.registry.Id, $scope.model) .then(function success() { - return RegistryV2Service.ping($scope.registry.Id, true); + return RegistryServiceSelector.ping($scope.registry, true); }) .then(function success() { Notifications.success('Success', 'Valid management configuration'); @@ -50,6 +51,7 @@ function ($scope, $state, $transition$, RegistryService, RegistryV2Service, Noti function initView() { var registryId = $transition$.params().id; + $scope.RegistryTypes = RegistryTypes; RegistryService.registry(registryId) .then(function success(data) { diff --git a/app/extensions/registry-management/views/configure/configureregistry.html b/app/extensions/registry-management/views/configure/configureregistry.html index 3325230b3..3bf998f48 100644 --- a/app/extensions/registry-management/views/configure/configureregistry.html +++ b/app/extensions/registry-management/views/configure/configureregistry.html @@ -32,7 +32,7 @@
-
+
-
+
diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepository.html b/app/extensions/registry-management/views/repositories/edit/registryRepository.html index 37da97c6a..ee431749c 100644 --- a/app/extensions/registry-management/views/repositories/edit/registryRepository.html +++ b/app/extensions/registry-management/views/repositories/edit/registryRepository.html @@ -48,6 +48,8 @@ Repository {{ repository.Name }} + + @@ -56,10 +58,12 @@ Tags count {{ repository.Tags.length }} + Images count {{ short.Images.length }} + diff --git a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js index b37856c54..30f9a6c1a 100644 --- a/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js +++ b/app/extensions/registry-management/views/repositories/edit/registryRepositoryController.js @@ -2,8 +2,8 @@ import _ from 'lodash-es'; import { RepositoryTagViewModel, RepositoryShortTag } from '../../../models/repositoryTag'; angular.module('portainer.app') - .controller('RegistryRepositoryController', ['$q', '$async', '$scope', '$uibModal', '$interval', '$transition$', '$state', 'RegistryV2Service', 'RegistryService', 'ModalService', 'Notifications', 'ImageHelper', - function ($q, $async, $scope, $uibModal, $interval, $transition$, $state, RegistryV2Service, RegistryService, ModalService, Notifications, ImageHelper) { + .controller('RegistryRepositoryController', ['$q', '$async', '$scope', '$uibModal', '$interval', '$transition$', '$state', 'RegistryServiceSelector', 'RegistryService', 'ModalService', 'Notifications', 'ImageHelper', + function ($q, $async, $scope, $uibModal, $interval, $transition$, $state, RegistryServiceSelector, RegistryService, ModalService, Notifications, ImageHelper) { $scope.state = { actionInProgress: false, @@ -63,7 +63,7 @@ angular.module('portainer.app') $scope.paginationAction = function (tags) { $scope.state.loading = true; - RegistryV2Service.getTagsDetails($scope.registryId, $scope.repository.Name, tags) + RegistryServiceSelector.getTagsDetails($scope.registry, $scope.repository.Name, tags) .then(function success(data) { for (var i = 0; i < data.length; i++) { var idx = _.findIndex($scope.tags, {'Name': data[i].Name}); @@ -86,7 +86,7 @@ angular.module('portainer.app') function createRetrieveAsyncGenerator() { $scope.state.tagsRetrieval.asyncGenerator = - RegistryV2Service.shortTagsWithProgress($scope.registryId, $scope.repository.Name, $scope.repository.Tags); + RegistryServiceSelector.shortTagsWithProgress($scope.registry, $scope.repository.Name, $scope.repository.Tags); } function resetTagsRetrievalState() { @@ -151,7 +151,7 @@ angular.module('portainer.app') } const tag = $scope.short.Tags.find((item) => item.ImageId === $scope.formValues.SelectedImage); const manifest = tag.ManifestV2; - await RegistryV2Service.addTag($scope.registryId, $scope.repository.Name, {tag: $scope.formValues.Tag, manifest: manifest}) + await RegistryServiceSelector.addTag($scope.registry, $scope.repository.Name, $scope.formValues.Tag, manifest) Notifications.success('Success', 'Tag successfully added'); $scope.short.Tags.push(new RepositoryShortTag($scope.formValues.Tag, tag.ImageId, tag.ImageDigest, tag.ManifestV2)); @@ -182,7 +182,7 @@ angular.module('portainer.app') function createRetagAsyncGenerator(modifiedTags, modifiedDigests, impactedTags) { $scope.state.tagsRetag.asyncGenerator = - RegistryV2Service.retagWithProgress($scope.registryId, $scope.repository.Name, modifiedTags, modifiedDigests, impactedTags); + RegistryServiceSelector.retagWithProgress($scope.registry, $scope.repository.Name, modifiedTags, modifiedDigests, impactedTags); } async function retagActionAsync() { @@ -252,7 +252,7 @@ angular.module('portainer.app') function createDeleteAsyncGenerator(modifiedDigests, impactedTags) { $scope.state.tagsDelete.asyncGenerator = - RegistryV2Service.deleteTagsWithProgress($scope.registryId, $scope.repository.Name, modifiedDigests, impactedTags); + RegistryServiceSelector.deleteTagsWithProgress($scope.registry, $scope.repository.Name, modifiedDigests, impactedTags); } async function removeTagsAsync(selectedTags) { @@ -289,7 +289,7 @@ angular.module('portainer.app') Notifications.success('Success', 'Tags successfully deleted'); if ($scope.short.Tags.length === 0) { - $state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true}); + $state.go('portainer.registries.registry.repositories', {id: $scope.registry.Id}, {reload: true}); } await loadRepositoryDetails(); } catch (err) { @@ -322,10 +322,10 @@ angular.module('portainer.app') try { const digests = _.uniqBy($scope.short.Tags, 'ImageDigest'); const promises = []; - _.map(digests, (item) => promises.push(RegistryV2Service.deleteManifest($scope.registryId, $scope.repository.Name, item.ImageDigest))); + _.map(digests, (item) => promises.push(RegistryServiceSelector.deleteManifest($scope.registry, $scope.repository.Name, item.ImageDigest))); await Promise.all(promises); Notifications.success('Success', 'Repository sucessfully removed'); - $state.go('portainer.registries.registry.repositories', {id: $scope.registryId}, {reload: true}); + $state.go('portainer.registries.registry.repositories', {id: $scope.registry.Id}, {reload: true}); } catch (err) { Notifications.error('Failure', err, 'Unable to delete repository'); } @@ -351,9 +351,9 @@ angular.module('portainer.app') */ async function loadRepositoryDetails() { try { - const registryId = $scope.registryId; + const registry = $scope.registry; const repository = $scope.repository.Name; - const tags = await RegistryV2Service.tags(registryId, repository); + const tags = await RegistryServiceSelector.tags(registry, repository); $scope.tags = []; $scope.repository.Tags = []; $scope.repository.Tags = _.sortBy(_.concat($scope.repository.Tags, _.without(tags.tags, null))); @@ -365,7 +365,7 @@ angular.module('portainer.app') async function initView() { try { - const registryId = $scope.registryId = $transition$.params().id; + const registryId = $transition$.params().id; $scope.repository.Name = $transition$.params().repository; $scope.state.loading = true; diff --git a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js index eace4f9f3..aed77e343 100644 --- a/app/extensions/registry-management/views/repositories/registryRepositoriesController.js +++ b/app/extensions/registry-management/views/repositories/registryRepositoriesController.js @@ -1,8 +1,10 @@ import _ from 'lodash-es'; +import { RegistryTypes } from 'Extensions/registry-management/models/registryTypes'; + angular.module('portainer.extensions.registrymanagement') -.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryV2Service', 'Notifications', 'Authentication', -function ($transition$, $scope, RegistryService, RegistryV2Service, Notifications, Authentication) { +.controller('RegistryRepositoriesController', ['$transition$', '$scope', 'RegistryService', 'RegistryServiceSelector', 'Notifications', 'Authentication', +function ($transition$, $scope, RegistryService, RegistryServiceSelector, Notifications, Authentication) { $scope.state = { displayInvalidConfigurationMessage: false, @@ -10,8 +12,11 @@ function ($transition$, $scope, RegistryService, RegistryV2Service, Notification }; $scope.paginationAction = function (repositories) { + if ($scope.registry.Type === RegistryTypes.GITLAB) { + return; + } $scope.state.loading = true; - RegistryV2Service.getRepositoriesDetails($scope.state.registryId, repositories) + RegistryServiceSelector.getRepositoriesDetails($scope.registry, repositories) .then(function success(data) { for (var i = 0; i < data.length; i++) { var idx = _.findIndex($scope.repositories, {'Name': data[i].Name}); @@ -30,20 +35,19 @@ function ($transition$, $scope, RegistryService, RegistryV2Service, Notification }; function initView() { - $scope.state.registryId = $transition$.params().id; + const registryId = $transition$.params().id; var authenticationEnabled = $scope.applicationState.application.authentication; if (authenticationEnabled) { $scope.isAdmin = Authentication.isAdmin(); } - RegistryService.registry($scope.state.registryId) + RegistryService.registry(registryId) .then(function success(data) { $scope.registry = data; - - RegistryV2Service.ping($scope.state.registryId, false) + RegistryServiceSelector.ping($scope.registry, false) .then(function success() { - return RegistryV2Service.catalog($scope.state.registryId); + return RegistryServiceSelector.repositories($scope.registry); }) .then(function success(data) { $scope.repositories = data; diff --git a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js index 4f34b89f1..9136302ae 100644 --- a/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js +++ b/app/extensions/registry-management/views/repositories/tag/registryRepositoryTagController.js @@ -6,12 +6,12 @@ import { RegistryImageDetailsViewModel } from 'Extensions/registry-management/mo class RegistryRepositoryTagController { /* @ngInject */ - constructor($transition$, $async, Notifications, RegistryService, RegistryV2Service, imagelayercommandFilter) { + constructor($transition$, $async, Notifications, RegistryService, RegistryServiceSelector, imagelayercommandFilter) { this.$transition$ = $transition$; this.$async = $async; this.Notifications = Notifications; this.RegistryService = RegistryService; - this.RegistryV2Service = RegistryV2Service; + this.RegistryServiceSelector = RegistryServiceSelector; this.imagelayercommandFilter = imagelayercommandFilter; this.context = {}; @@ -39,7 +39,7 @@ class RegistryRepositoryTagController { } try { this.registry = await this.RegistryService.registry(this.context.registryId); - this.tag = await this.RegistryV2Service.tag(this.context.registryId, this.context.repository, this.context.tag); + this.tag = await this.RegistryServiceSelector.tag(this.registry, this.context.repository, this.context.tag); const length = this.tag.History.length; this.history = _.map(this.tag.History, (layer, idx) => new RegistryImageLayerViewModel(length - idx, layer)); _.forEach(this.history, (item) => item.CreatedBy = this.imagelayercommandFilter(item.CreatedBy)) diff --git a/app/portainer/components/forms/registry-form-azure/registry-form-azure.html b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html index 9165749f7..1de3fdd9e 100644 --- a/app/portainer/components/forms/registry-form-azure/registry-form-azure.html +++ b/app/portainer/components/forms/registry-form-azure/registry-form-azure.html @@ -18,7 +18,7 @@
-
+
-
+