[PATCH] Support to upload ISO

Upload ISO to the path /var/lib/kimchi/iso of the local disk Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板" msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode +ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) + except OSError as e: + if e.errno == errno.EEXIST: + pass def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection): return resp + @cherrypy.expose + def upload(self, *args, **kwargs): + method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http { access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m; server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); }; kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script> <!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div> @@ -204,6 +207,16 @@ </div> </div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div> -- 1.7.9.5

Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板"
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode
+ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) Hi, Thanks for your patches! As I'm working on another patch of creating a dedicate storage pool, I suggest we create this dedicate pool when kimchi start up, and only make dirs does not make it a storage
On 2014年06月10日 10:56, ssdxiao wrote: pool. we need to call libvirt API. If you are willing to help on upload, could you pls rebase on my patch? Thanks!
+ except OSError as e: + if e.errno == errno.EEXIST: + pass
def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection):
return resp
+ @cherrypy.expose + def upload(self, *args, **kwargs):
I have tried cherrypy upload: https://bitbucket.org/cherrypy/cherrypy/issue/1068/file-upload-crashes-when-... And found cherrypy on https upload has bug, and this bug seems not back ported on ubuntu. Does cherrypy work for your code on https?
+ method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http {
access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m;
server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); };
kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script>
<!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div>
@@ -204,6 +207,16 @@ </div>
</div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div>

Thanks for your replay . I test my patch , I find nginx will proxy https to http, so my patch can work . Kimchi listens to 127.0.0.1 , and not allow other to access , why we must support https using cherrypy? . I will see your patch and work on upload with you . At 2014-06-10 11:52:35, "Royce Lv" <lvroyce@linux.vnet.ibm.com> wrote:
Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板"
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode
+ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) Hi, Thanks for your patches! As I'm working on another patch of creating a dedicate storage pool, I suggest we create this dedicate pool when kimchi start up, and only make dirs does not make it a storage
On 2014年06月10日 10:56, ssdxiao wrote: pool. we need to call libvirt API. If you are willing to help on upload, could you pls rebase on my patch? Thanks!
+ except OSError as e: + if e.errno == errno.EEXIST: + pass
def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection):
return resp
+ @cherrypy.expose + def upload(self, *args, **kwargs):
I have tried cherrypy upload: https://bitbucket.org/cherrypy/cherrypy/issue/1068/file-upload-crashes-when-... And found cherrypy on https upload has bug, and this bug seems not back ported on ubuntu. Does cherrypy work for your code on https?
+ method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http {
access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m;
server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); };
kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script>
<!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div>
@@ -204,6 +207,16 @@ </div>
</div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div>

Welcome to Kimchi, ssdxiao! It is always a pleasure to see new faces around here. I've just applied your patches and made some tests. I noticed you are not uploading new files but instead of that, you copy local ISOs files to a new pool. As Royce mentioned, she is creating a ootb pool to guide users to save their ISOs there. So those ISOs can easily be found by shallow scan. By uploading, I'd except to user provides a remote URL to Kimchi downloads the ISO and upload it to that pool. So we have 2 scenarios: 1) local -> local I am sure it is useful to user when he has the ootb pool. And it also duplicates the ISOs in different places in the host system In my mind, it would be better if the sysadmin moves the ISOs he wants to use in Kimchi to the ootb pool dir 2) remote -> local It means download an ISO from a remote URL and upload it to the ootb pool That way user doesn't need to do it manually if he doesn't want to use ISO streaming. On 06/09/2014 11:56 PM, ssdxiao wrote:
Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado."
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板"
msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode
+ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) + except OSError as e: + if e.errno == errno.EEXIST: + pass
def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection):
return resp
+ @cherrypy.expose + def upload(self, *args, **kwargs): + method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http {
access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m;
server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); };
kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script>
<!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div>
@@ -204,6 +207,16 @@ </div>
</div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div>

On 2014年06月12日 01:20, Aline Manera wrote:
Welcome to Kimchi, ssdxiao! It is always a pleasure to see new faces around here.
I've just applied your patches and made some tests. I noticed you are not uploading new files but instead of that, you copy local ISOs files to a new pool.
Aline, This piece of code: + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) Here the args passed by cherrypy http server, as I understand, is like a file stream from the client side, and this function writes the client side file to kimchi server directory. + fp.close() In my test I noticed it successfully transferred the ISO, which is different from the case when I was using the kimchi upload demo code. So I think we can consider to take in upload part for our release if the UI part can be polished a little. Thanks again ssdxiao!
As Royce mentioned, she is creating a ootb pool to guide users to save their ISOs there. So those ISOs can easily be found by shallow scan.
By uploading, I'd except to user provides a remote URL to Kimchi downloads the ISO and upload it to that pool.
So we have 2 scenarios: 1) local -> local I am sure it is useful to user when he has the ootb pool. And it also duplicates the ISOs in different places in the host system In my mind, it would be better if the sysadmin moves the ISOs he wants to use in Kimchi to the ootb pool dir
2) remote -> local It means download an ISO from a remote URL and upload it to the ootb pool That way user doesn't need to do it manually if he doesn't want to use ISO streaming.
On 06/09/2014 11:56 PM, ssdxiao wrote:
Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板" msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode +ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) + except OSError as e: + if e.errno == errno.EEXIST: + pass def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection): return resp + @cherrypy.expose + def upload(self, *args, **kwargs): + method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http { access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m; server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); }; kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script> <!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div> @@ -204,6 +207,16 @@ </div> </div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div>
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

Hi, Agree with Royce. This is the use case: Machine A(where the kimchid is running). Machine B (the browser which is connetting to the machine A's kimchi). Step1: upload the iso(locate on the B) from B to A. Step2: search the iso in A and create a template. Step3: use the template boot a vm. At 2014-06-12 04:30:38, "Royce Lv" <lvroyce@linux.vnet.ibm.com> wrote:
On 2014年06月12日 01:20, Aline Manera wrote:
Welcome to Kimchi, ssdxiao! It is always a pleasure to see new faces around here.
I've just applied your patches and made some tests. I noticed you are not uploading new files but instead of that, you copy local ISOs files to a new pool.
Aline,
This piece of code:
+ filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue())
Here the args passed by cherrypy http server, as I understand, is like a file stream from the client side, and this function writes the client side file to kimchi server directory.
+ fp.close()
In my test I noticed it successfully transferred the ISO, which is different from the case when I was using the kimchi upload demo code. So I think we can consider to take in upload part for our release if the UI part can be polished a little.
Thanks again ssdxiao!
As Royce mentioned, she is creating a ootb pool to guide users to save their ISOs there. So those ISOs can easily be found by shallow scan.
By uploading, I'd except to user provides a remote URL to Kimchi downloads the ISO and upload it to that pool.
So we have 2 scenarios: 1) local -> local I am sure it is useful to user when he has the ootb pool. And it also duplicates the ISOs in different places in the host system In my mind, it would be better if the sysadmin moves the ISOs he wants to use in Kimchi to the ootb pool dir
2) remote -> local It means download an ISO from a remote URL and upload it to the ootb pool That way user doesn't need to do it manually if he doesn't want to use ISO streaming.
On 06/09/2014 11:56 PM, ssdxiao wrote:
Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/contrib/kimchi.spec.fedora.in b/contrib/kimchi.spec.fedora.in index 2d4699b..771fccc 100644 --- a/contrib/kimchi.spec.fedora.in +++ b/contrib/kimchi.spec.fedora.in @@ -164,6 +164,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/contrib/kimchi.spec.suse.in b/contrib/kimchi.spec.suse.in index 165f566..ad6aed4 100644 --- a/contrib/kimchi.spec.suse.in +++ b/contrib/kimchi.spec.suse.in @@ -86,6 +86,7 @@ rm -rf $RPM_BUILD_ROOT %{_datadir}/kimchi/ui/js/kimchi.min.js %{_datadir}/kimchi/ui/js/jquery-ui.js %{_datadir}/kimchi/ui/js/jquery.min.js +%{_datadir}/kimchi/ui/js/resumable.js %{_datadir}/kimchi/ui/js/modernizr.custom.2.6.2.min.js %{_datadir}/kimchi/ui/js/novnc/*.js %{_datadir}/kimchi/ui/js/spice/*.js diff --git a/po/en_US.po b/po/en_US.po index 1ede7dc..6f5b100 100644 --- a/po/en_US.po +++ b/po/en_US.po @@ -1670,3 +1670,6 @@ msgstr "No templates found." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Upload ISO Image" diff --git a/po/pt_BR.po b/po/pt_BR.po index 5ff54e0..d4d26ee 100644 --- a/po/pt_BR.po +++ b/po/pt_BR.po @@ -1777,3 +1777,6 @@ msgstr "Nenhum modelo encontrado." msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "Carregar Imagem ISO" diff --git a/po/zh_CN.po b/po/zh_CN.po index caef515..da62131 100644 --- a/po/zh_CN.po +++ b/po/zh_CN.po @@ -1679,3 +1679,6 @@ msgstr "没有发现模板" msgid "Clone" msgstr "" + +msgid "Upload ISO Image" +msgstr "上传ISO镜像" \ No newline at end of file diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode +ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) + except OSError as e: + if e.errno == errno.EEXIST: + pass def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection): return resp + @cherrypy.expose + def upload(self, *args, **kwargs): + method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() + def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http { access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m; server { listen $proxy_ssl_port ssl; diff --git a/ui/css/theme-default/upload.css b/ui/css/theme-default/upload.css new file mode 100644 index 0000000..9cdfe4f --- /dev/null +++ b/ui/css/theme-default/upload.css @@ -0,0 +1,43 @@ +/* +Uploadify +Copyright (c) 2012 Reactive Apps, Ronnie Garcia +Released under the MIT License <http://www.opensource.org/licenses/mit-license.php> +*/ + +.uploadify-button { + background-color: #505050; + background-image: linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -o-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -moz-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -ms-linear-gradient(bottom, #505050 0%, #707070 100%); + background-image: -webkit-gradient( + linear, + left bottom, + left top, + color-stop(0, #505050), + color-stop(1, #707070) + ); + background-position: center top; + background-repeat: no-repeat; + -webkit-border-radius: 30px; + -moz-border-radius: 30px; + border-radius: 30px; + border: 2px solid #808080; + color: #FFF; + height: 30px; + width: 120px; + font: bold 12px Arial, Helvetica, sans-serif; + text-align: center; + text-shadow: 0 -1px 0 rgba(0,0,0,0.25); +} +.uploadify-progress { + background-color: #E5E5E5; + margin-top: 10px; + width: 100%; +} +.uploadify-progress-bar { + background-color: #0099FF; + height: 3px; + width: 1px; +} diff --git a/ui/js/resumable.js b/ui/js/resumable.js new file mode 100644 index 0000000..add21ec --- /dev/null +++ b/ui/js/resumable.js @@ -0,0 +1,816 @@ +/* +* MIT Licensed +* http://www.23developer.com/opensource +* http://github.com/23/resumable.js +* Steffen Tiedemann Christensen, steffen@23company.com +*/ + +(function(){ +"use strict"; + + var Resumable = function(opts){ + if ( !(this instanceof Resumable) ) { + return new Resumable(opts); + } + this.version = 1.0; + // SUPPORTED BY BROWSER? + // Check if these features are support by the browser: + // - File object type + // - Blob object type + // - FileList object type + // - slicing files + this.support = ( + (typeof(File)!=='undefined') + && + (typeof(Blob)!=='undefined') + && + (typeof(FileList)!=='undefined') + && + (!!Blob.prototype.webkitSlice||!!Blob.prototype.mozSlice||!!Blob.prototype.slice||false) + ); + if(!this.support) return(false); + + + // PROPERTIES + var $ = this; + $.files = []; + $.defaults = { + chunkSize:1*1024*1024, + forceChunkSize:false, + simultaneousUploads:3, + fileParameterName:'file', + throttleProgressCallbacks:0.5, + query:{}, + headers:{}, + preprocess:null, + method:'multipart', + prioritizeFirstAndLastChunk:false, + target:'/', + testChunks:true, + generateUniqueIdentifier:null, + maxChunkRetries:undefined, + chunkRetryInterval:undefined, + permanentErrors:[404, 415, 500, 501], + maxFiles:undefined, + withCredentials:false, + xhrTimeout:0, + maxFilesErrorCallback:function (files, errorCount) { + var maxFiles = $.getOpt('maxFiles'); + alert('Please upload ' + maxFiles + ' file' + (maxFiles === 1 ? '' : 's') + ' at a time.'); + }, + minFileSize:1, + minFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too small, please upload files larger than ' + $h.formatSize($.getOpt('minFileSize')) + '.'); + }, + maxFileSize:undefined, + maxFileSizeErrorCallback:function(file, errorCount) { + alert(file.fileName||file.name +' is too large, please upload files less than ' + $h.formatSize($.getOpt('maxFileSize')) + '.'); + }, + fileType: [], + fileTypeErrorCallback: function(file, errorCount) { + alert(file.fileName||file.name +' has type not allowed, please upload files of type ' + $.getOpt('fileType') + '.'); + } + }; + $.opts = opts||{}; + $.getOpt = function(o) { + var $opt = this; + // Get multiple option if passed an array + if(o instanceof Array) { + var options = {}; + $h.each(o, function(option){ + options[option] = $opt.getOpt(option); + }); + return options; + } + // Otherwise, just return a simple option + if ($opt instanceof ResumableChunk) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.fileObj; } + } + if ($opt instanceof ResumableFile) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { $opt = $opt.resumableObj; } + } + if ($opt instanceof Resumable) { + if (typeof $opt.opts[o] !== 'undefined') { return $opt.opts[o]; } + else { return $opt.defaults[o]; } + } + }; + + // EVENTS + // catchAll(event, ...) + // fileSuccess(file), fileProgress(file), fileAdded(file, event), fileRetry(file), fileError(file, message), + // complete(), progress(), error(message, file), pause() + $.events = []; + $.on = function(event,callback){ + $.events.push(event.toLowerCase(), callback); + }; + $.fire = function(){ + // `arguments` is an object, not array, in FF, so: + var args = []; + for (var i=0; i<arguments.length; i++) args.push(arguments[i]); + // Find event listeners, and support pseudo-event `catchAll` + var event = args[0].toLowerCase(); + for (var i=0; i<=$.events.length; i+=2) { + if($.events[i]==event) $.events[i+1].apply($,args.slice(1)); + if($.events[i]=='catchall') $.events[i+1].apply(null,args); + } + if(event=='fileerror') $.fire('error', args[2], args[1]); + if(event=='fileprogress') $.fire('progress'); + }; + + + // INTERNAL HELPER METHODS (handy, but ultimately not part of uploading) + var $h = { + stopEvent: function(e){ + e.stopPropagation(); + e.preventDefault(); + }, + each: function(o,callback){ + if(typeof(o.length)!=='undefined') { + for (var i=0; i<o.length; i++) { + // Array or FileList + if(callback(o[i])===false) return; + } + } else { + for (i in o) { + // Object + if(callback(i,o[i])===false) return; + } + } + }, + generateUniqueIdentifier:function(file){ + var custom = $.getOpt('generateUniqueIdentifier'); + if(typeof custom === 'function') { + return custom(file); + } + var relativePath = file.webkitRelativePath||file.fileName||file.name; // Some confusion in different versions of Firefox + var size = file.size; + return(size + '-' + relativePath.replace(/[^0-9a-zA-Z_-]/img, '')); + }, + contains:function(array,test) { + var result = false; + + $h.each(array, function(value) { + if (value == test) { + result = true; + return false; + } + return true; + }); + + return result; + }, + formatSize:function(size){ + if(size<1024) { + return size + ' bytes'; + } else if(size<1024*1024) { + return (size/1024.0).toFixed(0) + ' KB'; + } else if(size<1024*1024*1024) { + return (size/1024.0/1024.0).toFixed(1) + ' MB'; + } else { + return (size/1024.0/1024.0/1024.0).toFixed(1) + ' GB'; + } + }, + getTarget:function(params){ + var target = $.getOpt('target'); + if(target.indexOf('?') < 0) { + target += '?'; + } else { + target += '&'; + } + return target + params.join('&'); + } + }; + + var onDrop = function(event){ + $h.stopEvent(event); + appendFilesFromFileList(event.dataTransfer.files, event); + }; + var onDragOver = function(e) { + e.preventDefault(); + }; + + // INTERNAL METHODS (both handy and responsible for the heavy load) + var appendFilesFromFileList = function(fileList, event){ + // check for uploading too many files + var errorCount = 0; + var o = $.getOpt(['maxFiles', 'minFileSize', 'maxFileSize', 'maxFilesErrorCallback', 'minFileSizeErrorCallback', 'maxFileSizeErrorCallback', 'fileType', 'fileTypeErrorCallback']); + if (typeof(o.maxFiles)!=='undefined' && o.maxFiles<(fileList.length+$.files.length)) { + // if single-file upload, file is already added, and trying to add 1 new file, simply replace the already-added file + if (o.maxFiles===1 && $.files.length===1 && fileList.length===1) { + $.removeFile($.files[0]); + } else { + o.maxFilesErrorCallback(fileList, errorCount++); + return false; + } + } + var files = []; + $h.each(fileList, function(file){ + var fileName = file.name.split('.'); + var fileType = fileName[fileName.length-1].toLowerCase(); + + if (o.fileType.length > 0 && !$h.contains(o.fileType, fileType)) { + o.fileTypeErrorCallback(file, errorCount++); + return false; + } + + if (typeof(o.minFileSize)!=='undefined' && file.size<o.minFileSize) { + o.minFileSizeErrorCallback(file, errorCount++); + return false; + } + if (typeof(o.maxFileSize)!=='undefined' && file.size>o.maxFileSize) { + o.maxFileSizeErrorCallback(file, errorCount++); + return false; + } + + // directories have size == 0 + if (!$.getFromUniqueIdentifier($h.generateUniqueIdentifier(file))) {(function(){ + var f = new ResumableFile($, file); + window.setTimeout(function(){ + $.files.push(f); + files.push(f); + f.container = (typeof event != 'undefined' ? event.srcElement : null); + $.fire('fileAdded', f, event) + },0); + })()}; + }); + window.setTimeout(function(){ + $.fire('filesAdded', files) + },0); + }; + + // INTERNAL OBJECT TYPES + function ResumableFile(resumableObj, file){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $._prevProgress = 0; + $.resumableObj = resumableObj; + $.file = file; + $.fileName = file.fileName||file.name; // Some confusion in different versions of Firefox + $.size = file.size; + $.relativePath = file.webkitRelativePath || $.fileName; + $.uniqueIdentifier = $h.generateUniqueIdentifier(file); + $._pause = false; + $.container = ''; + var _error = false; + + // Callback when something happens within the chunk + var chunkEvent = function(event, message){ + // event can be 'progress', 'success', 'error' or 'retry' + switch(event){ + case 'progress': + $.resumableObj.fire('fileProgress', $); + break; + case 'error': + $.abort(); + _error = true; + $.chunks = []; + $.resumableObj.fire('fileError', $, message); + break; + case 'success': + if(_error) return; + $.resumableObj.fire('fileProgress', $); // it's at least progress + if($.isComplete()) { + $.resumableObj.fire('fileSuccess', $, message); + } + break; + case 'retry': + $.resumableObj.fire('fileRetry', $); + break; + } + }; + + // Main code to set up a file object with chunks, + // packaged to be able to handle retries if needed. + $.chunks = []; + $.abort = function(){ + // Stop current uploads + var abortCount = 0; + $h.each($.chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + abortCount++; + } + }); + if(abortCount>0) $.resumableObj.fire('fileProgress', $); + }; + $.cancel = function(){ + // Reset this file to be void + var _chunks = $.chunks; + $.chunks = []; + // Stop current uploads + $h.each(_chunks, function(c){ + if(c.status()=='uploading') { + c.abort(); + $.resumableObj.uploadNextChunk(); + } + }); + $.resumableObj.removeFile($); + $.resumableObj.fire('fileProgress', $); + }; + $.retry = function(){ + $.bootstrap(); + var firedRetry = false; + $.resumableObj.on('chunkingComplete', function(){ + if(!firedRetry) $.resumableObj.upload(); + firedRetry = true; + }); + }; + $.bootstrap = function(){ + $.abort(); + _error = false; + // Rebuild stack of chunks from file + $.chunks = []; + $._prevProgress = 0; + var round = $.getOpt('forceChunkSize') ? Math.ceil : Math.floor; + var maxOffset = Math.max(round($.file.size/$.getOpt('chunkSize')),1); + for (var offset=0; offset<maxOffset; offset++) {(function(offset){ + window.setTimeout(function(){ + $.chunks.push(new ResumableChunk($.resumableObj, $, offset, chunkEvent)); + $.resumableObj.fire('chunkingProgress',$,offset/maxOffset); + },0); + })(offset)} + window.setTimeout(function(){ + $.resumableObj.fire('chunkingComplete',$); + },0); + }; + $.progress = function(){ + if(_error) return(1); + // Sum up progress across everything + var ret = 0; + var error = false; + $h.each($.chunks, function(c){ + if(c.status()=='error') error = true; + ret += c.progress(true); // get chunk progress relative to entire file + }); + ret = (error ? 1 : (ret>0.999 ? 1 : ret)); + ret = Math.max($._prevProgress, ret); // We don't want to lose percentages when an upload is paused + $._prevProgress = ret; + return(ret); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.chunks, function(chunk){ + if(chunk.status()=='uploading') { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.isComplete = function(){ + var outstanding = false; + $h.each($.chunks, function(chunk){ + var status = chunk.status(); + if(status=='pending' || status=='uploading' || chunk.preprocessState === 1) { + outstanding = true; + return(false); + } + }); + return(!outstanding); + }; + $.pause = function(pause){ + if(typeof(pause)==='undefined'){ + $._pause = ($._pause ? false : true); + }else{ + $._pause = pause; + } + }; + $.isPaused = function() { + return $._pause; + }; + + + // Bootstrap and return + $.resumableObj.fire('chunkingStart', $); + $.bootstrap(); + return(this); + } + + function ResumableChunk(resumableObj, fileObj, offset, callback){ + var $ = this; + $.opts = {}; + $.getOpt = resumableObj.getOpt; + $.resumableObj = resumableObj; + $.fileObj = fileObj; + $.fileObjSize = fileObj.size; + $.fileObjType = fileObj.file.type; + $.offset = offset; + $.callback = callback; + $.lastProgressCallback = (new Date); + $.tested = false; + $.retries = 0; + $.pendingRetry = false; + $.preprocessState = 0; // 0 = unprocessed, 1 = processing, 2 = finished + + // Computed properties + var chunkSize = $.getOpt('chunkSize'); + $.loaded = 0; + $.startByte = $.offset*chunkSize; + $.endByte = Math.min($.fileObjSize, ($.offset+1)*chunkSize); + if ($.fileObjSize-$.endByte < chunkSize && !$.getOpt('forceChunkSize')) { + // The last chunk will be bigger than the chunk size, but less than 2*chunkSize + $.endByte = $.fileObjSize; + } + $.xhr = null; + + // test() makes a GET request without any data to see if the chunk has already been uploaded in a previous session + $.test = function(){ + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + var testHandler = function(e){ + $.tested = true; + var status = $.status(); + if(status=='success') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.send(); + } + }; + $.xhr.addEventListener('load', testHandler, false); + $.xhr.addEventListener('error', testHandler, false); + + // Add data from the query options + var params = []; + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + // Add extra data to identify chunk + params.push(['resumableChunkNumber', encodeURIComponent($.offset+1)].join('=')); + params.push(['resumableChunkSize', encodeURIComponent($.getOpt('chunkSize'))].join('=')); + params.push(['resumableCurrentChunkSize', encodeURIComponent($.endByte - $.startByte)].join('=')); + params.push(['resumableTotalSize', encodeURIComponent($.fileObjSize)].join('=')); + params.push(['resumableType', encodeURIComponent($.fileObjType)].join('=')); + params.push(['resumableIdentifier', encodeURIComponent($.fileObj.uniqueIdentifier)].join('=')); + params.push(['resumableFilename', encodeURIComponent($.fileObj.fileName)].join('=')); + params.push(['resumableRelativePath', encodeURIComponent($.fileObj.relativePath)].join('=')); + // Append the relevant chunk and send it + $.xhr.open('GET', $h.getTarget(params)); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(null); + }; + + $.preprocessFinished = function(){ + $.preprocessState = 2; + $.send(); + }; + + // send() uploads the actual data in a POST call + $.send = function(){ + var preprocess = $.getOpt('preprocess'); + if(typeof preprocess === 'function') { + switch($.preprocessState) { + case 0: preprocess($); $.preprocessState = 1; return; + case 1: return; + case 2: break; + } + } + if($.getOpt('testChunks') && !$.tested) { + $.test(); + return; + } + + // Set up request and listen for event + $.xhr = new XMLHttpRequest(); + + // Progress + $.xhr.upload.addEventListener('progress', function(e){ + if( (new Date) - $.lastProgressCallback > $.getOpt('throttleProgressCallbacks') * 1000 ) { + $.callback('progress'); + $.lastProgressCallback = (new Date); + } + $.loaded=e.loaded||0; + }, false); + $.loaded = 0; + $.pendingRetry = false; + $.callback('progress'); + + // Done (either done, failed or retry) + var doneHandler = function(e){ + var status = $.status(); + if(status=='success'||status=='error') { + $.callback(status, $.message()); + $.resumableObj.uploadNextChunk(); + } else { + $.callback('retry', $.message()); + $.abort(); + $.retries++; + var retryInterval = $.getOpt('chunkRetryInterval'); + if(retryInterval !== undefined) { + $.pendingRetry = true; + setTimeout($.send, retryInterval); + } else { + $.send(); + } + } + }; + $.xhr.addEventListener('load', doneHandler, false); + $.xhr.addEventListener('error', doneHandler, false); + + // Set up the basic query data from Resumable + var query = { + resumableChunkNumber: $.offset+1, + resumableChunkSize: $.getOpt('chunkSize'), + resumableCurrentChunkSize: $.endByte - $.startByte, + resumableTotalSize: $.fileObjSize, + resumableType: $.fileObjType, + resumableIdentifier: $.fileObj.uniqueIdentifier, + resumableFilename: $.fileObj.fileName, + resumableRelativePath: $.fileObj.relativePath, + resumableTotalChunks: $.fileObj.chunks.length + }; + // Mix in custom data + var customQuery = $.getOpt('query'); + if(typeof customQuery == 'function') customQuery = customQuery($.fileObj, $); + $h.each(customQuery, function(k,v){ + query[k] = v; + }); + + var func = ($.fileObj.file.slice ? 'slice' : ($.fileObj.file.mozSlice ? 'mozSlice' : ($.fileObj.file.webkitSlice ? 'webkitSlice' : 'slice'))), + bytes = $.fileObj.file[func]($.startByte,$.endByte), + data = null, + target = $.getOpt('target'); + + if ($.getOpt('method') === 'octet') { + // Add data from the query options + data = bytes; + var params = []; + $h.each(query, function(k,v){ + params.push([encodeURIComponent(k), encodeURIComponent(v)].join('=')); + }); + target = $h.getTarget(params); + } else { + // Add data from the query options + data = new FormData(); + $h.each(query, function(k,v){ + data.append(k,v); + }); + data.append($.getOpt('fileParameterName'), bytes); + } + + $.xhr.open('POST', target); + $.xhr.timeout = $.getOpt('xhrTimeout'); + $.xhr.withCredentials = $.getOpt('withCredentials'); + // Add data from header options + $h.each($.getOpt('headers'), function(k,v) { + $.xhr.setRequestHeader(k, v); + }); + $.xhr.send(data); + }; + $.abort = function(){ + // Abort and reset + if($.xhr) $.xhr.abort(); + $.xhr = null; + }; + $.status = function(){ + // Returns: 'pending', 'uploading', 'success', 'error' + if($.pendingRetry) { + // if pending retry then that's effectively the same as actively uploading, + // there might just be a slight delay before the retry starts + return('uploading'); + } else if(!$.xhr) { + return('pending'); + } else if($.xhr.readyState<4) { + // Status is really 'OPENED', 'HEADERS_RECEIVED' or 'LOADING' - meaning that stuff is happening + return('uploading'); + } else { + if($.xhr.status==200) { + // HTTP 200, perfect + return('success'); + } else if($h.contains($.getOpt('permanentErrors'), $.xhr.status) || $.retries >= $.getOpt('maxChunkRetries')) { + // HTTP 415/500/501, permanent error + return('error'); + } else { + // this should never happen, but we'll reset and queue a retry + // a likely case for this would be 503 service unavailable + $.abort(); + return('pending'); + } + } + }; + $.message = function(){ + return($.xhr ? $.xhr.responseText : ''); + }; + $.progress = function(relative){ + if(typeof(relative)==='undefined') relative = false; + var factor = (relative ? ($.endByte-$.startByte)/$.fileObjSize : 1); + if($.pendingRetry) return(0); + var s = $.status(); + switch(s){ + case 'success': + case 'error': + return(1*factor); + case 'pending': + return(0*factor); + default: + return($.loaded/($.endByte-$.startByte)*factor); + } + }; + return(this); + } + + // QUEUE + $.uploadNextChunk = function(){ + var found = false; + + // In some cases (such as videos) it's really handy to upload the first + // and last chunk of a file quickly; this let's the server check the file's + // metadata and determine if there's even a point in continuing. + if ($.getOpt('prioritizeFirstAndLastChunk')) { + $h.each($.files, function(file){ + if(file.chunks.length && file.chunks[0].status()=='pending' && file.chunks[0].preprocessState === 0) { + file.chunks[0].send(); + found = true; + return(false); + } + if(file.chunks.length>1 && file.chunks[file.chunks.length-1].status()=='pending' && file.chunks[file.chunks.length-1].preprocessState === 0) { + file.chunks[file.chunks.length-1].send(); + found = true; + return(false); + } + }); + if(found) return(true); + } + + // Now, simply look for the next, best thing to upload + $h.each($.files, function(file){ + if(file.isPaused()===false){ + $h.each(file.chunks, function(chunk){ + if(chunk.status()=='pending' && chunk.preprocessState === 0) { + chunk.send(); + found = true; + return(false); + } + }); + } + if(found) return(false); + }); + if(found) return(true); + + // The are no more outstanding chunks to upload, check is everything is done + var outstanding = false; + $h.each($.files, function(file){ + if(!file.isComplete()) { + outstanding = true; + return(false); + } + }); + if(!outstanding) { + // All chunks have been uploaded, complete + $.fire('complete'); + } + return(false); + }; + + + // PUBLIC METHODS FOR RESUMABLE.JS + $.assignBrowse = function(domNodes, isDirectory){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + var input; + if(domNode.tagName==='INPUT' && domNode.type==='file'){ + input = domNode; + } else { + input = document.createElement('input'); + input.setAttribute('type', 'file'); + input.style.display = 'none'; + domNode.addEventListener('click', function(){ + input.style.opacity = 0; + input.style.display='block'; + input.focus(); + input.click(); + input.style.display='none'; + }, false); + domNode.appendChild(input); + } + var maxFiles = $.getOpt('maxFiles'); + if (typeof(maxFiles)==='undefined'||maxFiles!=1){ + input.setAttribute('multiple', 'multiple'); + } else { + input.removeAttribute('multiple'); + } + if(isDirectory){ + input.setAttribute('webkitdirectory', 'webkitdirectory'); + } else { + input.removeAttribute('webkitdirectory'); + } + // When new files are added, simply append them to the overall list + input.addEventListener('change', function(e){ + appendFilesFromFileList(e.target.files,e); + e.target.value = ''; + }, false); + }); + }; + $.assignDrop = function(domNodes){ + if(typeof(domNodes.length)=='undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.addEventListener('dragover', onDragOver, false); + domNode.addEventListener('drop', onDrop, false); + }); + }; + $.unAssignDrop = function(domNodes) { + if (typeof(domNodes.length) == 'undefined') domNodes = [domNodes]; + + $h.each(domNodes, function(domNode) { + domNode.removeEventListener('dragover', onDragOver); + domNode.removeEventListener('drop', onDrop); + }); + }; + $.isUploading = function(){ + var uploading = false; + $h.each($.files, function(file){ + if (file.isUploading()) { + uploading = true; + return(false); + } + }); + return(uploading); + }; + $.upload = function(){ + // Make sure we don't start too many uploads at once + if($.isUploading()) return; + // Kick off the queue + $.fire('uploadStart'); + for (var num=1; num<=$.getOpt('simultaneousUploads'); num++) { + $.uploadNextChunk(); + } + }; + $.pause = function(){ + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + file.abort(); + }); + $.fire('pause'); + }; + $.cancel = function(){ + for(var i = $.files.length - 1; i >= 0; i--) { + $.files[i].cancel(); + } + $.fire('cancel'); + }; + $.progress = function(){ + var totalDone = 0; + var totalSize = 0; + // Resume all chunks currently being uploaded + $h.each($.files, function(file){ + totalDone += file.progress()*file.size; + totalSize += file.size; + }); + return(totalSize>0 ? totalDone/totalSize : 0); + }; + $.addFile = function(file, event){ + appendFilesFromFileList([file], event); + }; + $.removeFile = function(file){ + for(var i = $.files.length - 1; i >= 0; i--) { + if($.files[i] === file) { + $.files.splice(i, 1); + } + } + }; + $.getFromUniqueIdentifier = function(uniqueIdentifier){ + var ret = false; + $h.each($.files, function(f){ + if(f.uniqueIdentifier==uniqueIdentifier) ret = f; + }); + return(ret); + }; + $.getSize = function(){ + var totalSize = 0; + $h.each($.files, function(file){ + totalSize += file.size; + }); + return(totalSize); + }; + + return(this); + }; + + + // Node.js-style export for Node and Component + if (typeof module != 'undefined') { + module.exports = Resumable; + } else if (typeof define === "function" && define.amd) { + // AMD/requirejs: Define the module + define(function(){ + return Resumable; + }); + } else { + // Browser: Expose to window + window.Resumable = Resumable; + } + +})(); diff --git a/ui/js/src/kimchi.template_add_main.js b/ui/js/src/kimchi.template_add_main.js index dbb3952..5651424 100644 --- a/ui/js/src/kimchi.template_add_main.js +++ b/ui/js/src/kimchi.template_add_main.js @@ -387,6 +387,33 @@ kimchi.template_add_main = function() { } } }; + //1-3 upload iso + $('#iso-upload').click(function() { + kimchi.switchPage('iso-type-box', 'iso-upload-box'); + }); + + $('#iso-upload-box-back').click(function() { + kimchi.switchPage('iso-upload-box', 'iso-type-box', 'right'); + }); + + var r = new Resumable({ + target:'storagepools/upload' + }); + + r.on('fileProgress', function(file){ + console.debug(file); + var element=document.getElementById("upload"); + var progress = Math.round(file.progress()*100)+"%" + element.innerHTML=file.fileName+ "-" + progress; + var tmp=document.getElementById("movie"); + tmp.innerHTML=['<div class="uploadify-progress"><div class="uploadify-progress-bar" style="width:', progress,'"></div></div>'].join("") + }); + + r.on('fileAdded', function(file, event){ + r.upload(); + }); + + r.assignBrowse(document.getElementById('browseButton')); }; kimchi.template_check_url = function(url) { diff --git a/ui/pages/kimchi-ui.html.tmpl b/ui/pages/kimchi-ui.html.tmpl index 08b27a8..542cd43 100644 --- a/ui/pages/kimchi-ui.html.tmpl +++ b/ui/pages/kimchi-ui.html.tmpl @@ -38,6 +38,7 @@ <script src="$href('libs/jquery-ui.min.js')"></script> <script src="$href('libs/jquery-ui-i18n.min.js')"></script> <script src="$href('js/kimchi.min.js')"></script> +<script src="$href('js/resumable.js')"></script> <!-- This is used for detecting if the UI needs to be built --> <style type="text/css"> diff --git a/ui/pages/template-add.html.tmpl b/ui/pages/template-add.html.tmpl index afe22dd..ecda083 100644 --- a/ui/pages/template-add.html.tmpl +++ b/ui/pages/template-add.html.tmpl @@ -41,6 +41,9 @@ <li> <a id="iso-remote" class="remote">$_("Remote ISO Image")</a> </li> + <li> + <a id="iso-upload" class="local">$_("Upload ISO Image")</a> + </li> </ul> </div> @@ -204,6 +207,16 @@ </div> </div> + <!-- 1-3--> + <div class="page" id="iso-upload-box"> + <header> + <a class="back" id="iso-upload-box-back"></a> + <h2 class="step-title">$_("Upload ISO Image")</h2> + </header> + <a href="#" id="browseButton" class="uploadify-button">Select files</a> + <div id="upload"></div> + <div id="movie"></div> + </div> </div> </div> </div>
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

On 06/12/2014 05:55 AM, ssdxiao wrote:
Hi, Agree with Royce. This is the use case: Machine A(where the kimchid is running). Machine B (the browser which is connetting to the machine A's kimchi).
Step1: upload the iso(locate on the B) from B to A. Step2: search the iso in A and create a template. Step3: use the template boot a vm.
Got it. Thanks! I got messed as I tested it locally. =) About the patch, I agree if Royce - we need to improve the UI. You should split your patch set in small patches. I'd say 3 patches: 1) For the imported code That way we easily identify which patches we need to really review the code 2) For backend code 3) For UI code You added a new optional to create a template but it only uploads the ISO to Kimchi server. I'd expect it to upload and create the template itself The API should be: 1) POST /storagepools/<ootb-pool>/upload {'file': <filename>} This URI will be responsible to upload the file to Kimchi server and it will be only available for the storagepool resource 2) POST /templates/ {'cdrom': <path to file uploaded earlier>}

On 06/12/2014 12:24 PM, Aline Manera wrote:
On 06/12/2014 05:55 AM, ssdxiao wrote:
Hi, Agree with Royce. This is the use case: Machine A(where the kimchid is running). Machine B (the browser which is connetting to the machine A's kimchi).
Step1: upload the iso(locate on the B) from B to A. Step2: search the iso in A and create a template. Step3: use the template boot a vm.
Got it. Thanks! I got messed as I tested it locally. =)
About the patch, I agree if Royce - we need to improve the UI.
You should split your patch set in small patches. I'd say 3 patches:
1) For the imported code That way we easily identify which patches we need to really review the code 2) For backend code 3) For UI code
You added a new optional to create a template but it only uploads the ISO to Kimchi server. I'd expect it to upload and create the template itself
The API should be:
1) POST /storagepools/<ootb-pool>/upload {'file': <filename>} This URI will be responsible to upload the file to Kimchi server and it will be only available for the storagepool resource
2) POST /templates/ {'cdrom': <path to file uploaded earlier>}
I forgot to mention I got several errors (~ 700) on Firebug. All those like below:
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel
ÿØÿà

On 06/09/2014 11:56 PM, ssdxiao wrote:
Upload ISO to the path /var/lib/kimchi/iso of the local disk
Signed-off-by: ssdxiao <ssdxiao@163.com> --- contrib/kimchi.spec.fedora.in | 1 + contrib/kimchi.spec.suse.in | 1 + po/en_US.po | 3 + po/pt_BR.po | 3 + po/zh_CN.po | 3 + src/kimchi/control/storagepools.py | 28 +- src/nginx.conf.in | 1 + ui/css/theme-default/upload.css | 43 ++ ui/js/resumable.js | 816 +++++++++++++++++++++++++++++++++ ui/js/src/kimchi.template_add_main.js | 27 ++ ui/pages/kimchi-ui.html.tmpl | 1 + ui/pages/template-add.html.tmpl | 13 + 12 files changed, 938 insertions(+), 2 deletions(-) create mode 100644 ui/css/theme-default/upload.css create mode 100644 ui/js/resumable.js
diff --git a/src/kimchi/control/storagepools.py b/src/kimchi/control/storagepools.py index b75bca0..72b9f78 100644 --- a/src/kimchi/control/storagepools.py +++ b/src/kimchi/control/storagepools.py @@ -18,8 +18,8 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
import cherrypy - - +import os +import errno from kimchi.control.base import Collection, Resource from kimchi.control.storagevolumes import IsoVolumes, StorageVolumes from kimchi.control.utils import get_class_name, model_fn @@ -28,6 +28,9 @@ from kimchi.model.storagepools import ISO_POOL_NAME from kimchi.control.utils import UrlSubNode
+ISO_UPLOAD_DIR = "/var/lib/kimchi/iso/" + + @UrlSubNode("storagepools", True, ['POST', 'DELETE']) class StoragePools(Collection): def __init__(self, model): @@ -35,6 +38,11 @@ class StoragePools(Collection): self.resource = StoragePool isos = IsoPool(model) setattr(self, ISO_POOL_NAME, isos) + try: + os.makedirs(ISO_UPLOAD_DIR, mode=0755) + except OSError as e: + if e.errno == errno.EEXIST: + pass
def create(self, params, *args): try: @@ -57,6 +65,22 @@ class StoragePools(Collection):
return resp
+ @cherrypy.expose + def upload(self, *args, **kwargs): + method = cherrypy.request.method.upper() + if method != "POST": + raise cherrypy.HTTPError(405) + fileName = kwargs["resumableFilename"] + chunkSize = kwargs["resumableChunkSize"] + chunkNumber = kwargs["resumableChunkNumber"] + position = int(chunkSize) * (int(chunkNumber)-1) + + filePath = ISO_UPLOAD_DIR+fileName + fp = open(filePath, "a+") + fp.seek(position) + fp.write(kwargs["file"].fullvalue()) + fp.close() +
1) The upload action is related to a specific storage pool instance, so it must be in StoragePool(Resource) So the API will be: POST /storagepools/<pool-name>/upload {file: <filename>} The code should be like: # in StoragePool(Resource) self.upload = self.generate_action_handler('upload') 2) The upload logic is implemented in the models. The control only receive/parse the request and send the response it gets from model We have 2 models: src/model/ and src/mockmodel.py The first one is the real model (connects to libvirt and make all happen) and the second one is a fake model - only to make the tests easier to do # in src/kimchi/model/storagepools.py (StoragePoolModel(object)) def upload(self): # insert the upload code here # in src/mockmodel.py def storagepool_upload(self): # create a fake upload method 3) For new features (and this is the case), you need to add a new test case for it tests/test_rest.py tests/test_model.py tests/test_mockmodel.py 4) Make sure you code is ready for review by running "make check" and "make check-local"
def _get_resources(self, filter_params): try: res_list = super(StoragePools, self)._get_resources(filter_params) diff --git a/src/nginx.conf.in b/src/nginx.conf.in index 38e643d..9568476 100644 --- a/src/nginx.conf.in +++ b/src/nginx.conf.in @@ -37,6 +37,7 @@ http {
access_log /var/log/nginx/access.log main; sendfile on; + client_max_body_size 2m;
server { listen $proxy_ssl_port ssl;

ssdxiao, Royce's patches regarding to the ISO pool are already merged. You can rebase your patch on master branch, apply the comments made during review and resend the patches. If you need help to apply the review comments, let me know.
participants (3)
-
Aline Manera
-
Royce Lv
-
ssdxiao