From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id B2B718915B for ; Fri, 29 Jul 2022 11:30:17 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 9E52F1B4C3 for ; Fri, 29 Jul 2022 11:29:47 +0200 (CEST) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits) server-digest SHA256) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS for ; Fri, 29 Jul 2022 11:29:44 +0200 (CEST) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id 53FCC42C1F for ; Fri, 29 Jul 2022 11:29:44 +0200 (CEST) Message-ID: Date: Fri, 29 Jul 2022 11:29:41 +0200 MIME-Version: 1.0 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:91.0) Gecko/20100101 Thunderbird/91.11.0 Content-Language: en-US To: Proxmox VE development discussion References: <20220720122634.485474-1-m.heiserer@proxmox.com> From: Markus Frank In-Reply-To: <20220720122634.485474-1-m.heiserer@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.101 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment NICE_REPLY_A -0.001 Looks like a legit reply (A) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pve-devel] [PATCH v2 manager] fix #3248: GUI: storage: upload multiple files X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 29 Jul 2022 09:30:17 -0000 I tested this patch in a pve vm with multiple different iso files. Upload, Cancel, Remove and Exit work as intended. Tested-by: Markus Frank On 7/20/22 14:26, Matthias Heiserer wrote: > Queue multiple files for upload to the storage. > The upload itself happens in a separate window. > When closing the window, files with an error (i.e. wrong hash) > are retained in the upload window. > > Signed-off-by: Matthias Heiserer > --- > > Depends on https://lists.proxmox.com/pipermail/pbs-devel/2022-July/005365.html > Without that, trashcan icons are invisible. > > Changes from v1: > * separate into file selection window and upload window > * prohibit upload of files with invalid name or missing hash > * rename abort button to cancel > * prohibit upload of duplicate files (checked by name) > * move event handlers and initcomponet code to controller > * abort XHR when window is closed > * general code cleanup > * show tasklog only when pressing button > * display uploaded/total files and the current status at the top > > www/manager6/.lint-incremental | 0 > www/manager6/window/UploadToStorage.js | 633 +++++++++++++++++-------- > 2 files changed, 446 insertions(+), 187 deletions(-) > create mode 100644 www/manager6/.lint-incremental > > diff --git a/www/manager6/.lint-incremental b/www/manager6/.lint-incremental > new file mode 100644 > index 00000000..e69de29b > diff --git a/www/manager6/window/UploadToStorage.js b/www/manager6/window/UploadToStorage.js > index 0de6d89d..67780165 100644 > --- a/www/manager6/window/UploadToStorage.js > +++ b/www/manager6/window/UploadToStorage.js > @@ -1,9 +1,25 @@ > +Ext.define('pve-multiupload', { > + extend: 'Ext.data.Model', > + fields: [ > + 'file', 'filename', 'progressWidget', 'hashsum', 'hashValueWidget', > + 'xhr', 'mimetype', 'size', 'fileNameWidget', 'hashWidget', > + { > + name: 'done', defaultValue: false, > + }, > + { > + name: 'hash', defaultValue: '__default__', > + }, > + ], > +}); > Ext.define('PVE.window.UploadToStorage', { > extend: 'Ext.window.Window', > alias: 'widget.pveStorageUpload', > mixins: ['Proxmox.Mixin.CBind'], > + height: 400, > + width: 800, > > - resizable: false, > + resizable: true, > + scrollable: true, > modal: true, > > title: gettext('Upload'), > @@ -27,93 +43,405 @@ Ext.define('PVE.window.UploadToStorage', { > > viewModel: { > data: { > - size: '-', > - mimetype: '-', > - filename: '', > + validFiles: 0, > + numFiles: 0, > + invalidHash: 0, > }, > }, > - > controller: { > - submit: function(button) { > - const view = this.getView(); > - const form = this.lookup('formPanel').getForm(); > - const abortBtn = this.lookup('abortBtn'); > - const pbar = this.lookup('progressBar'); > - > - const updateProgress = function(per, bytes) { > - let text = (per * 100).toFixed(2) + '%'; > - if (bytes) { > - text += " (" + Proxmox.Utils.format_size(bytes) + ')'; > + init: function(view) { > + const me = this; > + me.lookup('grid').store.viewModel = me.getViewModel(); > + }, > + > + addFile: function(input) { > + const me = this; > + const grid = me.lookup('grid'); > + for (const file of input.fileInputEl.dom.files) { > + if (grid.store.findBy( > + record => record.get('file').name === file.name) >= 0 > + ) { > + continue; > + } > + grid.store.add({ > + file: file, > + filename: file.name, > + size: Proxmox.Utils.format_size(file.size), > + mimetype: file.type, > + }); > + } > + }, > + > + removeFileHandler: function(view, rowIndex, colIndex, item, event, record) { > + const me = this; > + me.removeFile(record); > + }, > + > + removeFile: function(record) { > + const me = this; > + const widget = record.get('fileNameWidget'); > + // set filename to invalid value, so when adding a new file with valid name, > + // the validityChange listener is called > + widget.setValue(""); > + me.lookup('grid').store.remove(record); > + }, > + > + openUploadWindow: function() { > + const me = this; > + const view = me.getView(); > + Ext.create('PVE.window.UploadProgress', { > + store: Ext.create('Ext.data.ChainedStore', { > + source: me.lookup('grid').store, > + }), > + nodename: view.nodename, > + storage: view.storage, > + content: view.content, > + autoShow: true, > + taskDone: view.taskDone, > + numFiles: me.getViewModel().get('numFiles'), > + listeners: { > + close: function() { > + const store = this.lookup('grid').store; > + store.each(function(record) { > + if (record.get('done')) { > + me.removeFile(record); > + } > + }); > + }, > + }, > + }); > + }, > + > + fileNameChange: function(widget, newValue, oldValue) { > + const record = widget.getWidgetRecord(); > + record.set('filename', newValue); > + }, > + > + fileNameValidityChange: function(widget, isValid) { > + const me = this; > + const current = me.getViewModel().get('validFiles'); > + if (isValid) { > + me.getViewModel().set('validFiles', current + 1); > + } else { > + me.getViewModel().set('validFiles', current - 1); > + } > + }, > + > + hashChange: function(widget, newValue, oldValue) { > + const record = widget.getWidgetRecord(); > + // hashChange is called once before on WidgetAttach, so skip that > + if (record) { > + record.set('hash', newValue); > + const hashValueWidget = record.get('hashValueWidget'); > + if (newValue === '__default__') { > + hashValueWidget.setValue(''); > + hashValueWidget.setDisabled(true); > + } else { > + hashValueWidget.setDisabled(false); > + hashValueWidget.validate(); > + } > + } > + }, > + > + hashValueChange: function(widget, newValue, oldValue) { > + const record = widget.getWidgetRecord(); > + record.set('hashsum', newValue); > + }, > + > + hashValueValidityChange: function(widget, isValid) { > + const vm = this.getViewModel(); > + vm.set('invalidHash', vm.get('invalidHash') + (isValid ? -1 : 1)); > + }, > + > + breakCyclicReferences: function(grid) { > + grid.store.each(record => grid.store.remove(record)); > + }, > + > + onHashWidgetAttach: function(col, widget, record) { > + record.set('hashWidget', widget); > + }, > + > + onHashValueWidgetAttach: function(col, widget, record) { > + record.set('hashValueWidget', widget); > + }, > + > + onFileNameWidgetAttach: function(col, widget, record) { > + record.set('fileNameWidget', widget); > + }, > + > + enableUploadOfMultipleFiles: function(filefield) { > + filefield.fileInputEl.dom.multiple = true; > + }, > + }, > + > + items: [ > + { > + xtype: 'grid', > + reference: 'grid', > + store: { > + listeners: { > + remove: function(_store, records) { > + records.forEach(record => { > + record.get('xhr')?.abort(); > + > + // cleanup so change event won't trigger when adding next file > + // as that would happen before the widget gets attached > + record.get('hashWidget').setValue('__default__'); > + > + // remove cyclic references to the widgets > + record.set('progressWidget', null); > + record.set('hashWidget', null); > + record.set('hashValueWidget', null); > + record.set('fileNameWidget', null); > + > + const me = this; > + const numFiles = me.viewModel.get('numFiles'); > + me.viewModel.set('numFiles', numFiles - 1); > + }); > + }, > + add: function(_store, records) { > + const me = this; > + const current = me.viewModel.get('numFiles'); > + me.viewModel.set('numFiles', current + 1); > + }, > + }, > + model: 'pve-multiupload', > + }, > + listeners: { > + beforedestroy: 'breakCyclicReferences', > + }, > + columns: [ > + { > + header: gettext('Source Name'), > + dataIndex: 'file', > + renderer: file => file.name, > + width: 200, > + }, > + { > + header: gettext('File Name'), > + dataIndex: 'filename', > + width: 300, > + xtype: 'widgetcolumn', > + widget: { > + xtype: 'textfield', > + listeners: { > + change: 'fileNameChange', > + validityChange: 'fileNameValidityChange', > + }, > + cbind: { > + regex: '{filenameRegex}', > + }, > + regexText: gettext('Wrong file extension'), > + allowBlank: false, > + }, > + onWidgetAttach: 'onFileNameWidgetAttach', > + }, > + { > + header: gettext('File size'), > + dataIndex: 'size', > + }, > + { > + header: gettext('MIME type'), > + dataIndex: 'mimetype', > + hidden: true, > + }, > + { > + xtype: 'actioncolumn', > + items: [{ > + iconCls: 'fa critical fa-trash-o', > + handler: 'removeFileHandler', > + }], > + width: 50, > + }, > + { > + header: gettext('Hash'), > + dataIndex: 'hash', > + width: 110, > + xtype: 'widgetcolumn', > + widget: { > + xtype: 'pveHashAlgorithmSelector', > + listeners: { > + change: 'hashChange', > + }, > + }, > + onWidgetAttach: 'onHashWidgetAttach', > + }, > + { > + header: gettext('Hash Value'), > + dataIndex: 'hashsum', > + renderer: data => data || 'None', > + width: 300, > + xtype: 'widgetcolumn', > + widget: { > + xtype: 'textfield', > + disabled: true, > + listeners: { > + change: 'hashValueChange', > + validityChange: 'hashValueValidityChange', > + }, > + allowBlank: false, > + }, > + onWidgetAttach: 'onHashValueWidgetAttach', > + }, > + ], > + }, > + ], > + > + buttons: [ > + { > + xtype: 'filefield', > + name: 'file', > + buttonText: gettext('Add File'), > + allowBlank: false, > + hideLabel: true, > + fieldStyle: 'display: none;', > + cbind: { > + accept: '{extensions}', > + }, > + listeners: { > + change: 'addFile', > + render: 'enableUploadOfMultipleFiles', > + }, > + }, > + { > + xtype: 'button', > + text: gettext('Cancel'), > + handler: function() { > + const me = this; > + me.up('pveStorageUpload').close(); > + }, > + }, > + { > + text: gettext('Start upload'), > + handler: 'openUploadWindow', > + bind: { > + disabled: '{numFiles == 0 || !(validFiles === numFiles) || invalidHash}', > + }, > + }, > + ], > +}); > + > +Ext.define('PVE.window.UploadProgress', { > + extend: 'Ext.window.Window', > + alias: 'widget.pveStorageUploadProgress', > + mixins: ['Proxmox.Mixin.CBind'], > + height: 400, > + width: 800, > + resizable: true, > + scrollable: true, > + modal: true, > + > + cbindData: function(initialConfig) { > + const me = this; > + const ext = me.acceptedExtensions[me.content] || []; > + > + me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`; > + > + return { > + extensions: ext.join(', '), > + filenameRegex: RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'), > + store: initialConfig.store, > + }; > + }, > + > + > + title: gettext('Upload Progress'), > + > + acceptedExtensions: { > + iso: ['.img', '.iso'], > + vztmpl: ['.tar.gz', '.tar.xz'], > + }, > + > + viewModel: { > + data: { > + numUploaded: 0, > + numFiles: 0, > + currentTask: '', > + }, > + > + formulas: { > + loadingLabel: function(get) { > + if (get('currentTask') === 'Copy files') { > + return 'x-grid-row-loading'; > } > - pbar.updateProgress(per, text); > - }; > + return ''; > + }, > + }, > + }, > + controller: { > + init: function(view) { > + const me = this; > + me.getViewModel().data.numFiles = view.numFiles; > + me.startUpload(); > + }, > + > + currentUploadIndex: 0, > + startUpload: function() { > + const me = this; > + const view = me.getView(); > + const grid = me.lookup('grid'); > + const vm = me.getViewModel(); > + > + const record = grid.store.getAt(me.currentUploadIndex++); > + if (!record) { > + vm.set('currentTask', 'Done'); > + return; > + } > > + const data = record.data; > const fd = new FormData(); > - > - button.setDisabled(true); > - abortBtn.setDisabled(false); > - > fd.append("content", view.content); > - > - const fileField = form.findField('file'); > - const file = fileField.fileInputEl.dom.files[0]; > - fileField.setDisabled(true); > - > - const filenameField = form.findField('filename'); > - const filename = filenameField.getValue(); > - filenameField.setDisabled(true); > - > - const algorithmField = form.findField('checksum-algorithm'); > - algorithmField.setDisabled(true); > - if (algorithmField.getValue() !== '__default__') { > - fd.append("checksum-algorithm", algorithmField.getValue()); > - > - const checksumField = form.findField('checksum'); > - fd.append("checksum", checksumField.getValue()?.trim()); > - checksumField.setDisabled(true); > + if (data.hash !== '__default__') { > + fd.append("checksum-algorithm", data.hash); > + fd.append("checksum", data.hashsum.trim()); > } > + fd.append("filename", data.file, data.filename); > > - fd.append("filename", file, filename); > - > - pbar.setVisible(true); > - updateProgress(0); > - > - const xhr = new XMLHttpRequest(); > - view.xhr = xhr; > > + const xhr = data.xhr = new XMLHttpRequest(); > xhr.addEventListener("load", function(e) { > + vm.set('currentTask', 'Copy files'); > if (xhr.status === 200) { > - view.hide(); > - > const result = JSON.parse(xhr.response); > const upid = result.data; > - Ext.create('Proxmox.window.TaskViewer', { > - autoShow: true, > + const taskviewer = Ext.create('Proxmox.window.TaskViewer', { > upid: upid, > - taskDone: view.taskDone, > - listeners: { > - destroy: function() { > - view.close(); > - }, > + taskDone: function(success) { > + vm.set('numUploaded', vm.get('numUploaded') + 1); > + if (success) { > + record.set('done', true); > + } else { > + const widget = record.get('progressWidget'); > + widget.updateProgress(0, "ERROR"); > + widget.setStyle('background-color', 'red'); > + } > + view.taskDone(); > + me.startUpload(); > }, > + closeAction: 'hide', > }); > + record.set('taskviewer', taskviewer); > + record.get('taskViewerButton').enable(); > + } else { > + const widget = record.get('progressWidget'); > + widget.updateProgress(0, `ERROR: ${xhr.status}`); > + widget.setStyle('background-color', 'red'); > + me.startUpload(); > + } > + }); > > - return; > + const updateProgress = function(per, bytes) { > + let text = (per * 100).toFixed(2) + '%'; > + if (bytes) { > + text += " (" + Proxmox.Utils.format_size(bytes) + ')'; > } > - const err = Ext.htmlEncode(xhr.statusText); > - let msg = `${gettext('Error')} ${xhr.status.toString()}: ${err}`; > - if (xhr.responseText !== "") { > - const result = Ext.decode(xhr.responseText); > - result.message = msg; > - msg = Proxmox.Utils.extractRequestError(result, true); > - } > - Ext.Msg.alert(gettext('Error'), msg, btn => view.close()); > - }, false); > + record.get('progressWidget').updateProgress(per, text); > + }; > > xhr.addEventListener("error", function(e) { > const err = e.target.status.toString(); > const msg = `Error '${err}' occurred while receiving the document.`; > - Ext.Msg.alert(gettext('Error'), msg, btn => view.close()); > + Ext.Msg.alert(gettext('Error'), msg, _ => view.close()); > }); > > xhr.upload.addEventListener("progress", function(evt) { > @@ -125,171 +453,102 @@ Ext.define('PVE.window.UploadToStorage', { > > xhr.open("POST", `/api2/json${view.url}`, true); > xhr.send(fd); > + vm.set('currentTask', 'Upload files'); > }, > > - validitychange: function(f, valid) { > - const submitBtn = this.lookup('submitBtn'); > - submitBtn.setDisabled(!valid); > + onProgressWidgetAttach: function(col, widget, rec) { > + rec.set('progressWidget', widget); > + widget.updateProgress(0, ""); > }, > > - fileChange: function(input) { > - const vm = this.getViewModel(); > - const name = input.value.replace(/^.*(\/|\\)/, ''); > - const fileInput = input.fileInputEl.dom; > - vm.set('filename', name); > - vm.set('size', (fileInput.files[0] && Proxmox.Utils.format_size(fileInput.files[0].size)) || '-'); > - vm.set('mimetype', (fileInput.files[0] && fileInput.files[0].type) || '-'); > + onWindowClose: function(panel) { > + const store = panel.lookup('grid').store; > + store.each(record => record.get('xhr')?.abort()); > }, > > - hashChange: function(field, value) { > - const checksum = this.lookup('downloadUrlChecksum'); > - if (value === '__default__') { > - checksum.setDisabled(true); > - checksum.setValue(""); > - } else { > - checksum.setDisabled(false); > - } > + showTaskViewer: function(button) { > + button.record.get('taskviewer').show(); > + }, > + > + onTaskButtonAttach: function(col, widget, rec) { > + widget.record = rec; > + rec.set('taskViewerButton', widget); > }, > }, > > items: [ > { > - xtype: 'form', > - reference: 'formPanel', > - method: 'POST', > - waitMsgTarget: true, > - bodyPadding: 10, > - border: false, > - width: 400, > - fieldDefaults: { > - labelWidth: 100, > - anchor: '100%', > - }, > - items: [ > + xtype: 'grid', > + reference: 'grid', > + > + cbind: { > + store: '{store}', > + }, > + columns: [ > { > - xtype: 'filefield', > - name: 'file', > - buttonText: gettext('Select File'), > - allowBlank: false, > - fieldLabel: gettext('File'), > - cbind: { > - accept: '{extensions}', > - }, > - listeners: { > - change: 'fileChange', > - }, > + header: gettext('File Name'), > + dataIndex: 'filename', > + flex: 4, > }, > { > - xtype: 'textfield', > - name: 'filename', > - allowBlank: false, > - fieldLabel: gettext('File name'), > - bind: { > - value: '{filename}', > - }, > - cbind: { > - regex: '{filenameRegex}', > + header: gettext('Progress Bar'), > + xtype: 'widgetcolumn', > + widget: { > + xtype: 'progressbar', > }, > - regexText: gettext('Wrong file extension'), > + onWidgetAttach: 'onProgressWidgetAttach', > + flex: 2, > }, > { > - xtype: 'displayfield', > - name: 'size', > - fieldLabel: gettext('File size'), > - bind: { > - value: '{size}', > + header: gettext('Task Viewer'), > + xtype: 'widgetcolumn', > + widget: { > + xtype: 'button', > + handler: 'showTaskViewer', > + disabled: true, > + text: 'Show', > }, > + onWidgetAttach: 'onTaskButtonAttach', > }, > + ], > + tbar: [ > { > xtype: 'displayfield', > - name: 'mimetype', > - fieldLabel: gettext('MIME type'), > bind: { > - value: '{mimetype}', > + value: 'Files uploaded: {numUploaded} / {numFiles}', > }, > }, > + '->', > { > - xtype: 'pveHashAlgorithmSelector', > - name: 'checksum-algorithm', > - fieldLabel: gettext('Hash algorithm'), > - allowBlank: true, > - hasNoneOption: true, > - value: '__default__', > - listeners: { > - change: 'hashChange', > + xtype: 'displayfield', > + bind: { > + value: '{currentTask}', > }, > }, > { > - xtype: 'textfield', > - name: 'checksum', > - fieldLabel: gettext('Checksum'), > - allowBlank: false, > - disabled: true, > - emptyText: gettext('none'), > - reference: 'downloadUrlChecksum', > - }, > - { > - xtype: 'progressbar', > - text: 'Ready', > - hidden: true, > - reference: 'progressBar', > - }, > - { > - xtype: 'hiddenfield', > - name: 'content', > - cbind: { > - value: '{content}', > + xtype: 'displayfield', > + userCls: 'x-grid-row-loading', > + width: 30, > + bind: { > + hidden: '{currentTask === "Done"}', > }, > }, > ], > - listeners: { > - validitychange: 'validitychange', > - }, > }, > ], > > buttons: [ > { > xtype: 'button', > - text: gettext('Abort'), > - reference: 'abortBtn', > - disabled: true, > + text: gettext('Exit'), > handler: function() { > const me = this; > - me.up('pveStorageUpload').close(); > + me.up('pveStorageUploadProgress').close(); > }, > }, > - { > - text: gettext('Upload'), > - reference: 'submitBtn', > - disabled: true, > - handler: 'submit', > - }, > ], > > listeners: { > - close: function() { > - const me = this; > - if (me.xhr) { > - me.xhr.abort(); > - delete me.xhr; > - } > - }, > - }, > - > - initComponent: function() { > - const me = this; > - > - if (!me.nodename) { > - throw "no node name specified"; > - } > - if (!me.storage) { > - throw "no storage ID specified"; > - } > - if (!me.acceptedExtensions[me.content]) { > - throw "content type not supported"; > - } > - > - me.callParent(); > + close: 'onWindowClose', > }, > });