Files
proxmox-git-pve-manager/www/manager6/window/UploadToStorage.js
T
dualfroz baaeedd60a fix #8015: ui: upload: let the user choose the .img image format
When uploading a '.img' file as import content, '.raw' is appended to
the file name, so the image is always treated as a raw image. But the
'.img' extension is also commonly used for other formats, for example
the Ubuntu cloud images are qcow2 images. Such an image is then
imported as raw disk, which results in a disk with the size of the
qcow2 file and a guest that does not boot.

The format cannot be derived from the '.img' extension, so show a
format selector for such files and only append the extension of the
selected format to the file name. The upload stays disabled until a
format is selected, so the choice is always an explicit one.

Signed-off-by: Michal Fox <me@dualfroz.com>
Link: https://lore.proxmox.com/20260929135840.2748665-2-me@dualfroz.com
2026-09-30 11:39:38 +02:00

354 lines
11 KiB
JavaScript

Ext.define('PVE.window.UploadToStorage', {
extend: 'Ext.window.Window',
alias: 'widget.pveStorageUpload',
mixins: ['Proxmox.Mixin.CBind'],
resizable: false,
modal: true,
title: gettext('Upload'),
acceptedExtensions: {
import: ['.ova', '.qcow2', '.raw', '.vmdk'],
iso: ['.img', '.iso'],
vztmpl: ['.tar.gz', '.tar.xz', '.tar.zst'],
},
// accepted for file selection, but the extension does not tell the image format, so the user
// has to select it and it gets appended as real extension
formatAliases: {
import: ['.img'],
},
cbindData: function (initialConfig) {
const me = this;
const ext = me.acceptedExtensions[me.content] || [];
me.url = `/nodes/${me.nodename}/storage/${me.storage}/upload`;
let fileSelectorExt = ext.concat(me.formatAliases[me.content] ?? []);
return {
extensions: fileSelectorExt.join(', '),
filenameRegex: new RegExp('^.*(?:' + ext.join('|').replaceAll('.', '\\.') + ')$', 'i'),
};
},
viewModel: {
data: {
size: '-',
mimetype: '-',
filename: '',
needsFormat: false,
},
},
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) + ')';
}
pbar.updateProgress(per, text);
};
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);
}
fd.append('filename', file, filename);
pbar.setVisible(true);
updateProgress(0);
const xhr = new XMLHttpRequest();
view.xhr = xhr;
xhr.addEventListener(
'load',
function (e) {
if (xhr.status === 200) {
view.hide();
const result = JSON.parse(xhr.response);
const upid = result.data;
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: upid,
taskDone: view.taskDone,
listeners: {
destroy: function () {
view.close();
},
},
});
return;
}
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,
);
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());
});
xhr.upload.addEventListener(
'progress',
function (evt) {
if (evt.lengthComputable) {
const percentComplete = evt.loaded / evt.total;
updateProgress(percentComplete, evt.loaded);
}
},
false,
);
xhr.open('POST', `/api2/json${view.url}`, true);
xhr.send(fd);
},
validitychange: function (f, valid) {
const submitBtn = this.lookup('submitBtn');
submitBtn.setDisabled(!valid);
},
fileChange: function (input) {
const me = this;
const vm = me.getViewModel();
const view = me.getView();
const name = input.value.replace(/^.*(\/|\\)/, '');
const aliases = view.formatAliases[view.content] ?? [];
const needsFormat = aliases.some((alias) => name.endsWith(alias));
me.selectedName = name;
vm.set('needsFormat', needsFormat);
me.lookup('imageFormat').setValue(null);
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) || '-');
// browsers hide the real path behind 'C:\fakepath\', so only show the file name
input.setRawValue(fileInput.files[0]?.name ?? '');
},
hashChange: function (field, value) {
const checksum = this.lookup('downloadUrlChecksum');
if (value === '__default__') {
checksum.setDisabled(true);
checksum.setValue('');
} else {
checksum.setDisabled(false);
}
},
formatChange: function (field, format) {
if (format) {
this.getViewModel().set('filename', `${this.selectedName}.${format}`);
}
},
},
items: [
{
xtype: 'form',
reference: 'formPanel',
method: 'POST',
waitMsgTarget: true,
bodyPadding: 10,
border: false,
width: 400,
fieldDefaults: {
labelWidth: 100,
anchor: '100%',
},
items: [
{
xtype: 'filefield',
name: 'file',
buttonText: gettext('Select File'),
allowBlank: false,
fieldLabel: gettext('File'),
cbind: {
accept: '{extensions}',
},
listeners: {
change: 'fileChange',
},
},
{
xtype: 'textfield',
name: 'filename',
allowBlank: false,
fieldLabel: gettext('File name'),
bind: {
value: '{filename}',
},
cbind: {
regex: '{filenameRegex}',
},
regexText: gettext('Wrong file extension'),
},
{
xtype: 'pveDiskFormatSelector',
reference: 'imageFormat',
fieldLabel: gettext('Format'),
allowBlank: false,
hidden: true,
disabled: true,
bind: {
hidden: '{!needsFormat}',
disabled: '{!needsFormat}',
},
listeners: {
change: 'formatChange',
},
},
{
xtype: 'displayfield',
name: 'size',
fieldLabel: gettext('File size'),
bind: {
value: '{size}',
},
},
{
xtype: 'displayfield',
name: 'mimetype',
fieldLabel: gettext('MIME type'),
bind: {
value: '{mimetype}',
},
},
{
xtype: 'pveHashAlgorithmSelector',
name: 'checksum-algorithm',
fieldLabel: gettext('Hash algorithm'),
allowBlank: true,
hasNoneOption: true,
value: '__default__',
listeners: {
change: 'hashChange',
},
},
{
xtype: 'textfield',
name: 'checksum',
fieldLabel: gettext('Checksum'),
allowBlank: false,
disabled: true,
emptyText: gettext('none'),
reference: 'downloadUrlChecksum',
},
{
xtype: 'displayfield',
userCls: 'pmx-hint',
value: gettext(
"Uploads are stored temporarily in '/var/tmp/', make sure there is enough free space.",
),
},
{
xtype: 'progressbar',
text: 'Ready',
hidden: true,
reference: 'progressBar',
},
{
xtype: 'hiddenfield',
name: 'content',
cbind: {
value: '{content}',
},
},
],
listeners: {
validitychange: 'validitychange',
},
},
],
buttons: [
{
xtype: 'button',
text: gettext('Abort'),
reference: 'abortBtn',
disabled: true,
handler: function () {
const me = this;
me.up('pveStorageUpload').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();
},
});