From: Matthias Heiserer <m.heiserer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [pve-devel] [PATCH v2 manager] fix #3248: GUI: storage: upload multiple files
Date: Wed, 20 Jul 2022 14:26:34 +0200 [thread overview]
Message-ID: <20220720122634.485474-1-m.heiserer@proxmox.com> (raw)
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 <m.heiserer@proxmox.com>
---
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',
},
});
--
2.30.2
next reply other threads:[~2022-07-20 12:26 UTC|newest]
Thread overview: 3+ messages / expand[flat|nested] mbox.gz Atom feed top
2022-07-20 12:26 Matthias Heiserer [this message]
2022-07-29 9:29 ` Markus Frank
2023-03-31 11:33 ` Matthias Heiserer
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20220720122634.485474-1-m.heiserer@proxmox.com \
--to=m.heiserer@proxmox.com \
--cc=pve-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox