* [pve-devel] [PATCH v9 qemu-server] Add API for VM import
@ 2021-06-10 10:20 Dominic Jäger
2021-06-10 10:20 ` [pve-devel] [PATCH v9 manager] Add GUI to import disk & VM Dominic Jäger
0 siblings, 1 reply; 2+ messages in thread
From: Dominic Jäger @ 2021-06-10 10:20 UTC (permalink / raw)
To: pve-devel
Extend qm importdisk/importovf functionality to the API.
Co-authored-by: Fabian Grünbichler <f.gruenbichler@proxmox.com>
Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
v9: unchanged
PVE/API2/Qemu.pm | 175 +++++++++++++++++++++++++++++++++++++++--
PVE/API2/Qemu/Makefile | 2 +-
PVE/API2/Qemu/OVF.pm | 68 ++++++++++++++++
PVE/QemuServer.pm | 50 ++++++++++--
PVE/QemuServer/OVF.pm | 10 ++-
5 files changed, 289 insertions(+), 16 deletions(-)
create mode 100644 PVE/API2/Qemu/OVF.pm
diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm
index bc313f9..62ad2c9 100644
--- a/PVE/API2/Qemu.pm
+++ b/PVE/API2/Qemu.pm
@@ -45,7 +45,6 @@ BEGIN {
}
}
-use Data::Dumper; # fixme: remove
use base qw(PVE::RESTHandler);
@@ -61,7 +60,19 @@ my $resolve_cdrom_alias = sub {
}
};
+my $parse_import_sources = sub {
+ my $param = shift;
+ my $import = {};
+ foreach my $pair (PVE::Tools::split_list($param)) {
+ my ($device, $diskimage) = split('=', $pair);
+ $import->{$device} = $diskimage;
+ }
+
+ return $import;
+};
+
my $NEW_DISK_RE = qr!^(([^/:\s]+):)?(\d+(\.\d+)?)$!;
+my $IMPORT_DISK_RE = qr!^([^/:\s]+):-1$!;
my $check_storage_access = sub {
my ($rpcenv, $authuser, $storecfg, $vmid, $settings, $default_storage) = @_;
@@ -84,6 +95,12 @@ my $check_storage_access = sub {
my $scfg = PVE::Storage::storage_config($storecfg, $storeid);
raise_param_exc({ storage => "storage '$storeid' does not support vm images"})
if !$scfg->{content}->{images};
+ } elsif ($volid =~ $IMPORT_DISK_RE) {
+ my $storeid = $1;
+ $rpcenv->check($authuser, "/storage/$storeid", ['Datastore.AllocateSpace']);
+ my $scfg = PVE::Storage::storage_config($storecfg, $storeid);
+ raise_param_exc({ storage => "storage '$storeid' does not support vm images"})
+ if !$scfg->{content}->{images};
} else {
PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $volid);
}
@@ -91,6 +108,13 @@ my $check_storage_access = sub {
$rpcenv->check($authuser, "/storage/$settings->{vmstatestorage}", ['Datastore.AllocateSpace'])
if defined($settings->{vmstatestorage});
+
+ if (defined($settings->{import_sources})) {
+ my $images = $parse_import_sources->($settings->{import_sources});
+ foreach my $source_image (values %$images) {
+ PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $source_image);
+ }
+ }
};
my $check_storage_access_clone = sub {
@@ -133,10 +157,25 @@ my $check_storage_access_clone = sub {
return $sharedvm;
};
+# Raise exception if $format is not supported by $storeid
+my $check_format_is_supported = sub {
+ my ($format, $storeid, $storecfg) = @_;
+ die "storage ID parameter must be passed to the sub" if !$storeid;
+ die "storage configuration must be passed to the sub" if !$storecfg;
+
+ return if !$format;
+
+ my (undef, $valid_formats) = PVE::Storage::storage_default_format($storecfg, $storeid);
+ my $supported = grep { $_ eq $format } @$valid_formats;
+
+ die "format '$format' is not supported on storage $storeid" if !$supported;
+};
+
+
# Note: $pool is only needed when creating a VM, because pool permissions
# are automatically inherited if VM already exists inside a pool.
my $create_disks = sub {
- my ($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $settings, $default_storage) = @_;
+ my ($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $settings, $default_storage, $import) = @_;
my $vollist = [];
@@ -192,6 +231,69 @@ my $create_disks = sub {
$disk->{size} = PVE::Tools::convert_size($size, 'kb' => 'b');
delete $disk->{format}; # no longer needed
$res->{$ds} = PVE::QemuServer::print_drive($disk);
+ } elsif ($volid =~ $IMPORT_DISK_RE) {
+ my $target_storage = $1;
+
+ my $source = $import->{$ds};
+ die "cannot import '$ds', no import source defined\n" if !$source;
+ $source = PVE::Storage::abs_filesystem_path($storecfg, $source, 1);
+ my $src_size = PVE::Storage::file_size_info($source);
+ die "Could not get file size of $source" if !defined($src_size);
+
+ $check_format_is_supported->($disk->{format}, $storeid, $storecfg);
+
+ my $dst_format = PVE::QemuServer::resolve_dst_disk_format(
+ $storecfg,
+ $storeid,
+ undef,
+ $disk->{format},
+ );
+ my $dst_volid = PVE::Storage::vdisk_alloc(
+ $storecfg,
+ $storeid,
+ $vmid,
+ $dst_format,
+ undef,
+ PVE::Tools::convert_size($src_size, 'b' => 'kb'),
+ );
+
+ print "Importing disk image '$source' as '$dst_volid'...\n";
+ eval {
+ local $SIG{INT} =
+ local $SIG{TERM} =
+ local $SIG{QUIT} =
+ local $SIG{HUP} =
+ local $SIG{PIPE} = sub { die "Interrupted by signal $!\n"; };
+
+ my $zeroinit = PVE::Storage::volume_has_feature(
+ $storecfg,
+ 'sparseinit',
+ $dst_volid,
+ );
+ PVE::Storage::activate_volumes($storecfg, [$dst_volid]);
+ PVE::QemuServer::qemu_img_convert(
+ $source,
+ $dst_volid,
+ $src_size,
+ undef,
+ $zeroinit,
+ );
+ PVE::Storage::deactivate_volumes($storecfg, [$dst_volid]);
+
+ };
+ if (my $err = $@) {
+ eval { PVE::Storage::vdisk_free($storecfg, $dst_volid) };
+ warn "Cleanup of $dst_volid failed: $@ \n" if $@;
+
+ die "Importing disk '$source' failed: $err\n" if $err;
+ }
+ push @$vollist, $dst_volid;
+ $disk->{file} = $dst_volid;
+ if ($ds !~ /^unused\d+$/) {
+ $disk->{size} = $src_size;
+ delete $disk->{format}; # no longer needed
+ }
+ $res->{$ds} = PVE::QemuServer::print_drive($disk);
} else {
PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg, $vmid, $volid);
@@ -218,7 +320,7 @@ my $create_disks = sub {
}
};
- eval { PVE::QemuConfig->foreach_volume($settings, $code); };
+ eval { PVE::QemuConfig->foreach_volume_full($settings, { include_unused => 1 }, $code); };
# free allocated images on error
if (my $err = $@) {
@@ -550,6 +652,13 @@ __PACKAGE__->register_method({
default => 0,
description => "Start VM after it was created successfully.",
},
+ import_sources => {
+ description => "\\0 delimited mapping of devices to disk images to import." .
+ "For example, scsi0=/mnt/nfs/image1.vmdk",
+ type => 'string',
+ format => 'device-image-pair-alist',
+ optional => 1,
+ },
}),
},
returns => {
@@ -615,21 +724,34 @@ __PACKAGE__->register_method({
&$check_cpu_model_access($rpcenv, $authuser, $param);
+ my $import_devices = $parse_import_sources->($param->{import_sources});
+
foreach my $opt (keys %$param) {
if (PVE::QemuServer::is_valid_drivename($opt)) {
my $drive = PVE::QemuServer::parse_drive($opt, $param->{$opt});
raise_param_exc({ $opt => "unable to parse drive options" }) if !$drive;
+ raise_param_exc({ $opt => "not marked for import, but import source defined" })
+ if $drive->{file} !~ $IMPORT_DISK_RE && $import_devices->{$opt};
+ raise_param_exc({ $opt => "marked for import, but no import source defined" })
+ if $drive->{file} =~ $IMPORT_DISK_RE && !$import_devices->{$opt};
PVE::QemuServer::cleanup_drive_path($opt, $storecfg, $drive);
$param->{$opt} = PVE::QemuServer::print_drive($drive);
}
}
+ foreach my $opt (keys %$import_devices) {
+ raise_param_exc({ import_sources => "$opt not marked for import, but import source defined" })
+ if !defined($param->{$opt});
+
+ }
PVE::QemuServer::add_random_macs($param);
} else {
my $keystr = join(' ', keys %$param);
raise_param_exc({ archive => "option conflicts with other options ($keystr)"}) if $keystr;
+ raise_param_exc({ import_sources => "cannot import existing disk and restore backup." }) if $param->{import_sources};
+
if ($archive eq '-') {
die "pipe requires cli environment\n"
if $rpcenv->{type} ne 'cli';
@@ -701,10 +823,22 @@ __PACKAGE__->register_method({
my $realcmd = sub {
my $conf = $param;
my $arch = PVE::QemuServer::get_vm_arch($conf);
+ my $import = $parse_import_sources->(extract_param($param, "import_sources"));
my $vollist = [];
eval {
- $vollist = &$create_disks($rpcenv, $authuser, $conf, $arch, $storecfg, $vmid, $pool, $param, $storage);
+ $vollist = &$create_disks(
+ $rpcenv,
+ $authuser,
+ $conf,
+ $arch,
+ $storecfg,
+ $vmid,
+ $pool,
+ $param,
+ $storage,
+ $import
+ );
if (!$conf->{boot}) {
my $devs = PVE::QemuServer::get_default_bootdevices($conf);
@@ -1181,11 +1315,17 @@ my $update_vm_api = sub {
die "cannot add non-replicatable volume to a replicated VM\n";
};
+ my $import_devices = $parse_import_sources->($param->{import_sources});
+
foreach my $opt (keys %$param) {
if (PVE::QemuServer::is_valid_drivename($opt)) {
# cleanup drive path
my $drive = PVE::QemuServer::parse_drive($opt, $param->{$opt});
raise_param_exc({ $opt => "unable to parse drive options" }) if !$drive;
+ raise_param_exc({ $opt => "not marked for import, but import source defined" })
+ if $drive->{file} !~ $IMPORT_DISK_RE && $import_devices->{$opt};
+ raise_param_exc({ $opt => "marked for import, but no import source defined" })
+ if $drive->{file} =~ $IMPORT_DISK_RE && !$import_devices->{$opt};
PVE::QemuServer::cleanup_drive_path($opt, $storecfg, $drive);
$check_replication->($drive);
$param->{$opt} = PVE::QemuServer::print_drive($drive);
@@ -1203,12 +1343,20 @@ my $update_vm_api = sub {
}
}
+ foreach my $opt (keys %$import_devices) {
+ raise_param_exc({ import_sources => "$opt not marked for import, but import source defined" })
+ if !defined($param->{$opt});
+
+ }
+
&$check_vm_modify_config_perm($rpcenv, $authuser, $vmid, undef, [@delete]);
&$check_vm_modify_config_perm($rpcenv, $authuser, $vmid, undef, [keys %$param]);
&$check_storage_access($rpcenv, $authuser, $storecfg, $vmid, $param);
+ delete $param->{import_sources};
+
my $updatefn = sub {
my $conf = PVE::QemuConfig->load_config($vmid);
@@ -1350,7 +1498,17 @@ my $update_vm_api = sub {
PVE::QemuServer::vmconfig_register_unused_drive($storecfg, $vmid, $conf, PVE::QemuServer::parse_drive($opt, $conf->{pending}->{$opt}))
if defined($conf->{pending}->{$opt});
- &$create_disks($rpcenv, $authuser, $conf->{pending}, $arch, $storecfg, $vmid, undef, {$opt => $param->{$opt}});
+ &$create_disks(
+ $rpcenv,
+ $authuser,
+ $conf->{pending},
+ $arch, $storecfg,
+ $vmid,
+ undef,
+ {$opt => $param->{$opt}},
+ undef,
+ {$opt => $import_devices->{$opt}}
+ );
} elsif ($opt =~ m/^serial\d+/) {
if ((!defined($conf->{$opt}) || $conf->{$opt} eq 'socket') && $param->{$opt} eq 'socket') {
$rpcenv->check_vm_perm($authuser, $vmid, undef, ['VM.Config.HWType']);
@@ -1494,6 +1652,13 @@ __PACKAGE__->register_method({
optional => 1,
requires => 'delete',
},
+ import_sources => {
+ description => "\\0 delimited mapping of devices to disk images to import." .
+ "For example, scsi0=/mnt/nfs/image1.vmdk",
+ type => 'string',
+ format => 'device-image-pair-alist',
+ optional => 1,
+ },
digest => {
type => 'string',
description => 'Prevent changes if current configuration file has different SHA1 digest. This can be used to prevent concurrent modifications.',
diff --git a/PVE/API2/Qemu/Makefile b/PVE/API2/Qemu/Makefile
index 5d4abda..bdd4762 100644
--- a/PVE/API2/Qemu/Makefile
+++ b/PVE/API2/Qemu/Makefile
@@ -1,4 +1,4 @@
-SOURCES=Agent.pm CPU.pm Machine.pm
+SOURCES=Agent.pm CPU.pm Machine.pm OVF.pm
.PHONY: install
install:
diff --git a/PVE/API2/Qemu/OVF.pm b/PVE/API2/Qemu/OVF.pm
new file mode 100644
index 0000000..bd6e90b
--- /dev/null
+++ b/PVE/API2/Qemu/OVF.pm
@@ -0,0 +1,68 @@
+package PVE::API2::Qemu::OVF;
+
+use strict;
+use warnings;
+
+use PVE::RESTHandler;
+use PVE::JSONSchema qw(get_standard_option);
+use PVE::QemuServer::OVF;
+
+use base qw(PVE::RESTHandler);
+
+__PACKAGE__->register_method ({
+ name => 'index',
+ path => '',
+ method => 'GET',
+ proxyto => 'node',
+ description => "Read an .ovf manifest.",
+ parameters => {
+ additionalProperties => 0,
+ properties => {
+ node => get_standard_option('pve-node'),
+ manifest => {
+ description => ".ovf manifest",
+ type => 'string',
+ },
+ },
+ },
+ returns => {
+ description => "VM config according to .ovf manifest and digest of manifest",
+ type => "object",
+ },
+ returns => {
+ type => 'object',
+ additionalProperties => 1,
+ properties => PVE::QemuServer::json_ovf_properties({
+ name => {
+ type => 'string',
+ optional => 1,
+ },
+ cores => {
+ type => 'integer',
+ optional => 1,
+ },
+ memory => {
+ type => 'integer',
+ optional => 1,
+ },
+ }),
+ },
+ code => sub {
+ my ($param) = @_;
+
+ my $manifest = $param->{manifest};
+ die "$manifest: non-existent or non-regular file\n" if (! -f $manifest);
+
+ my $parsed = PVE::QemuServer::OVF::parse_ovf($manifest, 0, 1);
+ my $result;
+ $result->{cores} = $parsed->{qm}->{cores};
+ $result->{name} = $parsed->{qm}->{name};
+ $result->{memory} = $parsed->{qm}->{memory};
+ my $disks = $parsed->{disks};
+ foreach my $disk (@$disks) {
+ $result->{$disk->{disk_address}} = $disk->{backing_file};
+ }
+ return $result;
+ }});
+
+1;
\ No newline at end of file
diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm
index d77981c..7b74166 100644
--- a/PVE/QemuServer.pm
+++ b/PVE/QemuServer.pm
@@ -987,19 +987,41 @@ PVE::JSONSchema::register_format('pve-volume-id-or-qm-path', \&verify_volume_id_
sub verify_volume_id_or_qm_path {
my ($volid, $noerr) = @_;
- if ($volid eq 'none' || $volid eq 'cdrom' || $volid =~ m|^/|) {
- return $volid;
- }
+ return $volid eq 'none' || $volid eq 'cdrom' ?
+ $volid :
+ verify_volume_id_or_absolute_path($volid, $noerr);
+}
+
+PVE::JSONSchema::register_format('pve-volume-id-or-absolute-path', \&verify_volume_id_or_absolute_path);
+sub verify_volume_id_or_absolute_path {
+ my ($volid, $noerr) = @_;
+
+ return $volid if $volid =~ m|^/|;
- # if its neither 'none' nor 'cdrom' nor a path, check if its a volume-id
$volid = eval { PVE::JSONSchema::check_format('pve-volume-id', $volid, '') };
if ($@) {
- return if $noerr;
+ return undef if $noerr;
die $@;
}
return $volid;
}
+PVE::JSONSchema::register_format('device-image-pair', \&verify_device_image_pair);
+sub verify_device_image_pair {
+ my ($pair, $noerr) = @_;
+
+ my $error = sub {
+ return undef if $noerr;
+ die @_;
+ };
+
+ my ($device, $image) = split('=', $pair);
+ $error->("Invalid device '$device'") if !PVE::QemuServer::Drive::is_valid_drivename($device);
+ $error->("Invalid image '$image'") if !verify_volume_id_or_absolute_path($image);
+
+ return $pair;
+}
+
my $usb_fmt = {
host => {
default_key => 1,
@@ -2075,6 +2097,22 @@ sub json_config_properties {
return $prop;
}
+# Properties that we can read from an OVF file
+sub json_ovf_properties {
+ my $prop = shift;
+
+ foreach my $device ( PVE::QemuServer::Drive::valid_drive_names()) {
+ $prop->{$device} = {
+ type => 'string',
+ format => 'pve-volume-id-or-absolute-path',
+ description => "Disk image that gets imported to $device",
+ optional => 1,
+ };
+ }
+
+ return $prop;
+}
+
# return copy of $confdesc_cloudinit to generate documentation
sub cloudinit_config_properties {
@@ -6930,7 +6968,7 @@ sub qemu_img_convert {
$src_path = PVE::Storage::path($storecfg, $src_volid, $snapname);
$src_is_iscsi = ($src_path =~ m|^iscsi://|);
$cachemode = 'none' if $src_scfg->{type} eq 'zfspool';
- } elsif (-f $src_volid) {
+ } elsif (-f $src_volid || -b $src_volid) {
$src_path = $src_volid;
if ($src_path =~ m/\.($PVE::QemuServer::Drive::QEMU_FORMAT_RE)$/) {
$src_format = $1;
diff --git a/PVE/QemuServer/OVF.pm b/PVE/QemuServer/OVF.pm
index c76c199..48146e9 100644
--- a/PVE/QemuServer/OVF.pm
+++ b/PVE/QemuServer/OVF.pm
@@ -87,7 +87,7 @@ sub id_to_pve {
# returns two references, $qm which holds qm.conf style key/values, and \@disks
sub parse_ovf {
- my ($ovf, $debug) = @_;
+ my ($ovf, $debug, $manifest_only) = @_;
my $dom = XML::LibXML->load_xml(location => $ovf, no_blanks => 1);
@@ -220,9 +220,11 @@ ovf:Item[rasd:InstanceID='%s']/rasd:ResourceType", $controller_id);
die "error parsing $filepath, file seems not to exist at $backing_file_path\n";
}
- my $virtual_size;
- if ( !($virtual_size = PVE::Storage::file_size_info($backing_file_path)) ) {
- die "error parsing $backing_file_path, size seems to be $virtual_size\n";
+ my $virtual_size = undef;
+ if (!$manifest_only) { # Not possible if manifest is uploaded in web gui
+ if ( !($virtual_size = PVE::Storage::file_size_info($backing_file_path)) ) {
+ die "error parsing $backing_file_path: Could not get file size info: $@\n";
+ }
}
$pve_disk = {
--
2.30.2
^ permalink raw reply [flat|nested] 2+ messages in thread
* [pve-devel] [PATCH v9 manager] Add GUI to import disk & VM
2021-06-10 10:20 [pve-devel] [PATCH v9 qemu-server] Add API for VM import Dominic Jäger
@ 2021-06-10 10:20 ` Dominic Jäger
0 siblings, 0 replies; 2+ messages in thread
From: Dominic Jäger @ 2021-06-10 10:20 UTC (permalink / raw)
To: pve-devel
Add GUI wizard to import whole VMs and a window to import single disks in
Hardware View.
Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
v9: Use Tabpanel instead of Advanced Checkbox for disk options
PVE/API2/Nodes.pm | 7 +
www/manager6/Makefile | 4 +
www/manager6/form/ControllerSelector.js | 15 +
www/manager6/form/DiskStorageSelector.js | 27 +-
www/manager6/form/FileSelector.js | 6 +
www/manager6/qemu/CDEdit.js | 3 -
www/manager6/qemu/CreateWizard.js | 102 ++++++-
www/manager6/qemu/HardwareView.js | 4 +-
www/manager6/qemu/OSDefaults.js | 13 +
www/manager6/qemu/OSTypeEdit.js | 12 +-
www/manager6/qemu/disk/DiskBasic.js | 365 +++++++++++++++++++++++
www/manager6/qemu/disk/DiskCollection.js | 275 +++++++++++++++++
www/manager6/qemu/disk/DiskOptions.js | 243 +++++++++++++++
www/manager6/qemu/disk/HardDisk.js | 137 +++++++++
www/manager6/window/Wizard.js | 2 +
15 files changed, 1203 insertions(+), 12 deletions(-)
create mode 100644 www/manager6/qemu/disk/DiskBasic.js
create mode 100644 www/manager6/qemu/disk/DiskCollection.js
create mode 100644 www/manager6/qemu/disk/DiskOptions.js
create mode 100644 www/manager6/qemu/disk/HardDisk.js
diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index f4d3382c..94faeab1 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -48,6 +48,7 @@ use PVE::API2::LXC;
use PVE::API2::Network;
use PVE::API2::NodeConfig;
use PVE::API2::Qemu::CPU;
+use PVE::API2::Qemu::OVF;
use PVE::API2::Qemu;
use PVE::API2::Replication;
use PVE::API2::Services;
@@ -70,6 +71,11 @@ __PACKAGE__->register_method ({
path => 'qemu',
});
+__PACKAGE__->register_method ({
+ subclass => "PVE::API2::Qemu::OVF",
+ path => 'readovf',
+});
+
__PACKAGE__->register_method ({
subclass => "PVE::API2::LXC",
path => 'lxc',
@@ -2152,6 +2158,7 @@ __PACKAGE__->register_method ({
return undef;
}});
+
# bash completion helper
sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 6776d4ce..85b12e1c 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -199,6 +199,10 @@ JSSRC= \
qemu/CreateWizard.js \
qemu/DisplayEdit.js \
qemu/HDEdit.js \
+ qemu/disk/DiskCollection.js \
+ qemu/disk/HardDisk.js \
+ qemu/disk/DiskBasic.js \
+ qemu/disk/DiskOptions.js \
qemu/HDEfi.js \
qemu/HDMove.js \
qemu/HDResize.js \
diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js
index daca2432..eefa36ac 100644
--- a/www/manager6/form/ControllerSelector.js
+++ b/www/manager6/form/ControllerSelector.js
@@ -72,6 +72,21 @@ Ext.define('PVE.form.ControllerSelector', {
deviceid.validate();
},
+ getValues: function() {
+ return this.query('field').map(x => x.getValue());
+ },
+
+ getValuesAsString: function() {
+ return this.getValues().join('');
+ },
+
+ setValue: function(value) {
+ const regex = /([a-z]+)(\d+)/;
+ const [_, controller, deviceid] = regex.exec(value);
+ this.down('field[name=controller]').setValue(controller);
+ this.down('field[name=deviceid]').setValue(deviceid);
+ },
+
initComponent: function() {
var me = this;
diff --git a/www/manager6/form/DiskStorageSelector.js b/www/manager6/form/DiskStorageSelector.js
index cf73f2e2..15ac236e 100644
--- a/www/manager6/form/DiskStorageSelector.js
+++ b/www/manager6/form/DiskStorageSelector.js
@@ -25,8 +25,9 @@ Ext.define('PVE.form.DiskStorageSelector', {
// hideSelection is not true
hideSelection: undefined,
- // hides the size field (e.g, for the efi disk dialog)
+ // hides and disables the size field (e.g, for the efi disk dialog)
hideSize: false,
+ disableSize: false, // only disable the size field
// sets the initial size value
// string because else we get a type confusion
@@ -72,7 +73,7 @@ Ext.define('PVE.form.DiskStorageSelector', {
hdfilesel.setStorage(value);
}
- hdsizesel.setDisabled(select || me.hideSize);
+ hdsizesel.setDisabled(select || me.hideSize || me.disableSize);
hdsizesel.setVisible(!select && !me.hideSize);
},
@@ -85,6 +86,26 @@ Ext.define('PVE.form.DiskStorageSelector', {
hdfilesel.setNodename(nodename);
},
+ setSize: function(size) {
+ const me = this;
+ const hdsizesel = me.getComponent('disksize');
+ hdsizesel.setValue(size);
+ },
+
+ getSize: function() {
+ return this.getComponent('disksize').getValue();
+ },
+
+ fixAndGetSize: function() {
+ const me = this;
+ const field = me.getComponent('disksize');
+ if (!field.isValid()) {
+ field.clearInvalid();
+ field.setValue(me.defaultSize);
+ }
+ return field.getValue();
+ },
+
setDisabled: function(value) {
var me = this;
var hdstorage = me.getComponent('hdstorage');
@@ -140,7 +161,7 @@ Ext.define('PVE.form.DiskStorageSelector', {
name: 'disksize',
fieldLabel: gettext('Disk size') + ' (GiB)',
hidden: me.hideSize,
- disabled: me.hideSize,
+ disabled: me.hideSize || me.disableSize,
minValue: 0.001,
maxValue: 128*1024,
decimalPrecision: 3,
diff --git a/www/manager6/form/FileSelector.js b/www/manager6/form/FileSelector.js
index ef2bedf9..d426e7f4 100644
--- a/www/manager6/form/FileSelector.js
+++ b/www/manager6/form/FileSelector.js
@@ -51,6 +51,12 @@ Ext.define('PVE.form.FileSelector', {
this.setStorage(undefined, nodename);
},
+ getCurrentSize: function() {
+ const me = this;
+ const id = me.getValue();
+ return id ? me.store.getById(id).get('size') : 0;
+ },
+
store: {
model: 'pve-storage-content',
},
diff --git a/www/manager6/qemu/CDEdit.js b/www/manager6/qemu/CDEdit.js
index 72c01037..27092d32 100644
--- a/www/manager6/qemu/CDEdit.js
+++ b/www/manager6/qemu/CDEdit.js
@@ -84,9 +84,6 @@ Ext.define('PVE.qemu.CDInputPanel', {
checked: true,
listeners: {
change: function(f, value) {
- if (!me.rendered) {
- return;
- }
me.down('field[name=cdstorage]').setDisabled(!value);
var cdImageField = me.down('field[name=cdimage]');
cdImageField.setDisabled(!value);
diff --git a/www/manager6/qemu/CreateWizard.js b/www/manager6/qemu/CreateWizard.js
index d4535c9d..d066bd47 100644
--- a/www/manager6/qemu/CreateWizard.js
+++ b/www/manager6/qemu/CreateWizard.js
@@ -16,12 +16,25 @@ Ext.define('PVE.qemu.CreateWizard', {
nodename: undefined,
},
+ setImport: function(isImport = true) {
+ const me = this;
+ if (me.xtype !== 'pveQemuCreateWizard') {
+ throw "Unexpected xtype";
+ }
+ me.down('pveQemuOSTypePanel').ignoreDisks = isImport; // prefer values from OVF
+ // radiofield onChange behavior does not deactivate remaining radiofields
+ // when the panel is not yet rendered in ExtJS>=7.0
+ me.down('radiofield[inputValue=iso]').setValue(false);
+ me.down('radiofield[inputValue=none]').setValue(true);
+ },
+
subject: gettext('Virtual Machine'),
items: [
{
xtype: 'inputpanel',
title: gettext('General'),
+ itemId: 'generalPanel',
onlineHelp: 'qm_general_settings',
column1: [
{
@@ -63,6 +76,75 @@ Ext.define('PVE.qemu.CreateWizard', {
value: '',
allowBlank: true,
},
+ {
+ xtype: 'label',
+ itemId: 'successTextfield',
+ hidden: true,
+ html: gettext('Manifest successfully uploaded'),
+ margin: '0 0 0 10',
+ },
+ {
+ xtype: 'textfield',
+ name: 'ovfTextfield',
+ emptyText: '/mnt/nfs/exported.ovf',
+ fieldLabel: '.ovf manifest',
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Absolute path to an .ovf manifest on the PVE host'),
+ },
+ value: '/mnt/pve/nasi_private/importing/from_hyperv/pve_ovf/pve/pve.ovf', // TODO DOMINIC Remove after testing
+ listeners: {
+ validitychange: function(_, isValid) {
+ const button = Ext.ComponentQuery.query('#load_remote_manifest_button').pop();
+ button.setDisabled(!isValid);
+ },
+ },
+ validator: function(value) {
+ return (value && value.startsWith('/')) || gettext("Must start with /");
+ },
+ },
+ {
+ xtype: 'proxmoxButton',
+ itemId: 'load_remote_manifest_button',
+ text: gettext('Load .ovf'),
+ disabled: true,
+ handler: function() {
+ const inputpanel = this.up('#generalPanel');
+ const nodename = inputpanel.down('pveNodeSelector').getValue();
+ const ovfTextfieldValue = inputpanel.down('textfield[name=ovfTextfield]').getValue();
+ const wizard = this.up('window');
+ Proxmox.Utils.API2Request({
+ url: '/nodes/' + nodename + '/readovf',
+ method: 'GET',
+ params: {
+ manifest: ovfTextfieldValue,
+ },
+ success: function(response) {
+ const ovfdata = response.result.data;
+
+ wizard.down('textfield[name=name]').setValue(ovfdata.name);
+ wizard.down('#cpupanel').getViewModel().set('coreCount', ovfdata.cores);
+ wizard.down('#memorypanel').down('pveMemoryField').setValue(ovfdata.memory);
+
+ delete ovfdata.name;
+ delete ovfdata.cores;
+ delete ovfdata.memory;
+ delete ovfdata.digest;
+
+ const devices = Object.keys(ovfdata); // e.g. ide0, sata2
+ const hdcollection = wizard.down('pveQemuDiskCollection');
+ hdcollection.removeAllDisks(); // does nothing if already empty
+ devices.forEach(device => hdcollection.addDisk(device, ovfdata[device]));
+
+ wizard.setImport();
+ wizard.validcheck();
+ },
+ failure: function(response) {
+ Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+ },
+ });
+ },
+ },
],
advancedColumn1: [
{
@@ -120,6 +202,7 @@ Ext.define('PVE.qemu.CreateWizard', {
delete values.order;
delete values.up;
delete values.down;
+ delete values.ovfTextfield;
return values;
},
@@ -154,7 +237,7 @@ Ext.define('PVE.qemu.CreateWizard', {
insideWizard: true,
},
{
- xtype: 'pveQemuHDInputPanel',
+ xtype: 'pveQemuDiskCollection',
bind: {
nodename: '{nodename}',
},
@@ -163,11 +246,13 @@ Ext.define('PVE.qemu.CreateWizard', {
insideWizard: true,
},
{
+ itemId: 'cpupanel',
xtype: 'pveQemuProcessorPanel',
insideWizard: true,
title: gettext('CPU'),
},
{
+ itemId: 'memorypanel',
xtype: 'pveQemuMemoryPanel',
insideWizard: true,
title: gettext('Memory'),
@@ -235,6 +320,7 @@ Ext.define('PVE.qemu.CreateWizard', {
var nodename = kv.nodename;
delete kv.nodename;
+ delete kv.delete;
Proxmox.Utils.API2Request({
url: '/nodes/' + nodename + '/qemu',
@@ -251,6 +337,20 @@ Ext.define('PVE.qemu.CreateWizard', {
},
},
],
+
+ getValues: function() {
+ let values = this.callParent();
+ for (const [key, value] of Object.entries(values)) {
+ const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/;
+ if (key.match(re) && Array.isArray(value)) {
+ // Collected from different panels => array
+ // But API & some GUI functions expect not array
+ const sep = key === 'import_sources' ? '\0' : ',';
+ values[key] = value.join(sep);
+ }
+ }
+ return values;
+ },
});
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 200e3c28..1618fd17 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -630,9 +630,9 @@ Ext.define('PVE.qemu.HardwareView', {
iconCls: 'fa fa-fw fa-hdd-o black',
disabled: !caps.vms['VM.Config.Disk'],
handler: function() {
- let win = Ext.create('PVE.qemu.HDEdit', {
+ let win = Ext.create('PVE.qemu.HardDiskWindow', {
url: '/api2/extjs/' + baseurl,
- pveSelNode: me.pveSelNode,
+ nodename: me.pveSelNode.data.node,
});
win.on('destroy', me.reload, me);
win.show();
diff --git a/www/manager6/qemu/OSDefaults.js b/www/manager6/qemu/OSDefaults.js
index eed9eebc..9faf3ad6 100644
--- a/www/manager6/qemu/OSDefaults.js
+++ b/www/manager6/qemu/OSDefaults.js
@@ -72,6 +72,19 @@ Ext.define('PVE.qemu.OSDefaults', {
pveOS: 'wxp',
parent: 'w2k',
});
+ addOS({
+ pveOS: 'win10',
+ parent: 'generic',
+ busPriority: {
+ sata: 4, // for compatibility
+ ide: 3,
+ virtio: 2,
+ scsi: 1,
+ },
+ networkCard: 'e1000',
+ scsihw: '',
+ });
+
me.getDefaults = function(ostype) {
if (PVE.qemu.OSDefaults[ostype]) {
diff --git a/www/manager6/qemu/OSTypeEdit.js b/www/manager6/qemu/OSTypeEdit.js
index 438d7c6b..641d9394 100644
--- a/www/manager6/qemu/OSTypeEdit.js
+++ b/www/manager6/qemu/OSTypeEdit.js
@@ -3,6 +3,7 @@ Ext.define('PVE.qemu.OSTypeInputPanel', {
alias: 'widget.pveQemuOSTypePanel',
onlineHelp: 'qm_os_settings',
insideWizard: false,
+ ignoreDisks: false,
controller: {
xclass: 'Ext.app.ViewController',
@@ -20,13 +21,18 @@ Ext.define('PVE.qemu.OSTypeInputPanel', {
},
onOSTypeChange: function(field) {
var me = this, ostype = field.getValue();
- if (!me.getView().insideWizard) {
+ const view = me.getView();
+ if (!view.insideWizard) {
return;
}
var targetValues = PVE.qemu.OSDefaults.getDefaults(ostype);
-
- me.setWidget('pveBusSelector', targetValues.busType);
+ if (!view.ignoreDisks) {
+ const ids = Ext.ComponentQuery.query('pveBusSelector')
+ .reduce((acc, cur) => acc.concat(cur.id), []);
+ ids.forEach(i => me.setWidget(`#${i}`, targetValues.busType));
+ }
me.setWidget('pveNetworkCardSelector', targetValues.networkCard);
+ me.setWidget('pveQemuBiosSelector', targetValues.bios);
var scsihw = targetValues.scsihw || '__default__';
this.getViewModel().set('current.scsihw', scsihw);
},
diff --git a/www/manager6/qemu/disk/DiskBasic.js b/www/manager6/qemu/disk/DiskBasic.js
new file mode 100644
index 00000000..c89bbd37
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskBasic.js
@@ -0,0 +1,365 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.DiskBasic', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskBasic',
+ onlineHelp: 'qm_hard_disk',
+
+ insideWizard: false,
+
+ unused: false,
+
+ padding: '10 10 10 10',
+
+ vmconfig: {}, // used to select usused disks
+
+ viewModel: {},
+
+ /**
+ * All radiofields in pveQemuDiskCollection have the same scope
+ * Make name of radiofields unique for each disk
+ */
+ getRadioName() {
+ return 'radio_' + this.id;
+ },
+
+ onGetValues: function(values) {
+ let me = this;
+
+ let params = {};
+ let confid = me.confid || values.controller + values.deviceid;
+
+ const isImport = values.sourceVolid || values.sourcePath;
+ if (me.unused) {
+ me.drive.file = me.vmconfig[values.unusedId];
+ confid = values.controller + values.deviceid;
+ } else if (me.isCreate) {
+ if (values.hdimage) {
+ me.drive.file = values.hdimage;
+ } else if (isImport) {
+ me.drive.file = `${values.hdstorage}:-1`;
+ } else {
+ me.drive.file = values.hdstorage + ":" + values.disksize;
+ }
+ me.drive.format = values.diskformat;
+ }
+
+ PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
+ PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
+
+ if (isImport) {
+ // exactly 1 of sourceVolid and sourcePath must be defined
+ params.import_sources = `${confid}=${isImport}`;
+ }
+
+ params[confid] = PVE.Parser.printQemuDrive(me.drive);
+
+ return params;
+ },
+
+ setVMConfig: function(vmconfig) {
+ let me = this;
+
+ me.vmconfig = vmconfig;
+
+ if (me.bussel) {
+ me.bussel.setVMConfig(vmconfig);
+ me.scsiController.setValue(vmconfig.scsihw);
+ }
+ if (me.unusedDisks) {
+ let disklist = [];
+ Ext.Object.each(vmconfig, function(key, value) {
+ if (key.match(/^unused\d+$/)) {
+ disklist.push([key, value]);
+ }
+ });
+ me.unusedDisks.store.loadData(disklist);
+ me.unusedDisks.setValue(me.confid);
+ }
+ },
+
+ setDrive: function(drive) {
+ let me = this;
+
+ me.drive = drive;
+
+ let values = {};
+ let match = drive.file.match(/^([^:]+):/);
+ if (match) {
+ values.hdstorage = match[1];
+ }
+
+ values.hdimage = drive.file;
+ values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
+ values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
+ values.diskformat = drive.format || 'raw';
+ values.cache = drive.cache || '__default__';
+ values.discard = drive.discard === 'on';
+ values.ssd = PVE.Parser.parseBoolean(drive.ssd);
+ values.iothread = PVE.Parser.parseBoolean(drive.iothread);
+
+ values.mbps_rd = drive.mbps_rd;
+ values.mbps_wr = drive.mbps_wr;
+ values.iops_rd = drive.iops_rd;
+ values.iops_wr = drive.iops_wr;
+ values.mbps_rd_max = drive.mbps_rd_max;
+ values.mbps_wr_max = drive.mbps_wr_max;
+ values.iops_rd_max = drive.iops_rd_max;
+ values.iops_wr_max = drive.iops_wr_max;
+
+ me.setValues(values);
+ },
+
+ getDevice: function() {
+ return this.bussel.getValuesAsString();
+ },
+
+ setNodename: function(nodename) {
+ let me = this;
+ me.down('#hdstorage').setNodename(nodename);
+ me.down('#sourceStorageSelector').setNodename(nodename);
+ me.down('field[name=sourceVolid]').setNodename(nodename);
+ },
+
+ initComponent: function() {
+ let me = this;
+
+
+ me.drive = {};
+
+ me.column1 = [];
+ me.column2 = [];
+
+ if (!me.confid || me.unused) {
+ const controllerColumn = me.column2;
+ me.scsiController = Ext.create('Ext.form.field.Display', {
+ fieldLabel: gettext('SCSI Controller'),
+ reference: 'scsiController',
+ name: 'scsiController',
+ bind: me.insideWizard ? {
+ value: '{current.scsihw}',
+ } : undefined,
+ renderer: PVE.Utils.render_scsihw,
+ submitValue: false,
+ hidden: true,
+ });
+
+ me.bussel = Ext.create('PVE.form.ControllerSelector', {
+ itemId: 'bussel',
+ vmconfig: me.insideWizard ? { ide2: 'cdrom' } : {},
+ });
+
+ me.bussel.down('field[name=controller]').addListener('change', function(_, newValue) {
+ const allowIOthread = newValue.match(/^(virtio|scsi)/);
+ const iothreadField = me.next('pveQemuDiskOptions').down('field[name=iothread]');
+ iothreadField.setDisabled(!allowIOthread);
+ if (!allowIOthread) {
+ iothreadField.setValue(false);
+ }
+
+ const virtio = newValue.match(/^virtio/);
+ const ssdField = me.next('pveQemuDiskOptions').down('field[name=ssd]');
+ ssdField.setDisabled(virtio);
+ if (virtio) {
+ ssdField.setValue(false);
+ }
+
+ me.scsiController.setVisible(newValue.match(/^scsi/));
+ });
+
+ controllerColumn.push(me.bussel);
+ controllerColumn.push(me.scsiController);
+ }
+
+ if (me.unused) {
+ me.unusedDisks = Ext.create('Proxmox.form.KVComboBox', {
+ name: 'unusedId',
+ fieldLabel: gettext('Disk image'),
+ matchFieldWidth: false,
+ listConfig: {
+ width: 350,
+ },
+ data: [],
+ allowBlank: false,
+ });
+ me.column1.push(me.unusedDisks);
+ } else if (me.isCreate) {
+ let selector = {
+ xtype: 'pveDiskStorageSelector',
+ storageContent: 'images',
+ name: 'disk',
+ nodename: me.nodename,
+ autoSelect: me.insideWizard,
+ };
+ selector.storageLabel = gettext('Target storage');
+ me.column2.push(selector);
+ } else {
+ me.column1.push({
+ xtype: 'textfield',
+ disabled: true,
+ submitValue: false,
+ fieldLabel: gettext('Disk image'),
+ name: 'hdimage',
+ });
+ }
+
+ me.column2.push(
+ {
+ xtype: 'CacheTypeSelector',
+ name: 'cache',
+ value: '__default__',
+ fieldLabel: gettext('Cache'),
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Discard'),
+ reference: 'discard',
+ name: 'discard',
+ },
+ );
+ me.column1.unshift(
+ {
+ xtype: 'radiofield',
+ itemId: 'empty',
+ name: me.getRadioName(),
+ inputValue: 'empty',
+ boxLabel: gettext('Add empty disk'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ checked: true,
+ listeners: {
+ /**
+ *
+ * @param field - The radiofield
+ * @param nowSelected - True if the field has just been clicked on, false if
+ * any other radiofield has been clicked
+ */
+ change: function(field, nowSelected) {
+ // clicking buttons
+ me.down('#disksize').setDisabled(!nowSelected);
+ me.down('#disksize').clearInvalid();
+ // overrule storage specific setting (including initial load)
+ me.down('pveDiskStorageSelector').disableSize = !nowSelected;
+
+ const targetSelector = field.up('pveQemuHardDisk')
+ .down('pveDiskStorageSelector');
+ if (nowSelected) {
+ if (!me.newDiskSize) {
+ me.newDiskSize = targetSelector.defaultSize;
+ }
+ targetSelector.setSize(me.newDiskSize);
+ }
+ },
+ },
+ },
+ {
+ xtype: 'radiofield',
+ name: me.getRadioName(),
+ inputValue: 'storage',
+ boxLabel: gettext('Use a storage as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ listeners: {
+ change: (field, nowSelected) => {
+ field.next('#sourceStorageSelector').setHidden(!nowSelected);
+ field.next('#sourceStorageSelector').setDisabled(!nowSelected);
+ field.next('pveFileSelector[name=sourceVolid]').setHidden(!nowSelected);
+ field.next('pveFileSelector[name=sourceVolid]').setDisabled(!nowSelected);
+
+ // changing radiofields without changing source image
+ if (nowSelected) {
+ const targetSelector = me.down('pveDiskStorageSelector');
+ if (field.getGroupValue() === 'empty') {
+ // in this case the change listener of the 'empty' field fires with false AFTER this listener fires with true
+ me.newDiskSize = targetSelector.fixAndGetSize();
+ }
+ const sourceField = field.next('pveFileSelector');
+ const size = Proxmox.Utils.format_size(sourceField.getCurrentSize());
+ targetSelector.setSize(size);
+ }
+ },
+ },
+ }, {
+ xtype: 'pveStorageSelector',
+ itemId: 'sourceStorageSelector',
+ nodename: me.nodename,
+ fieldLabel: gettext('Storage'),
+ storageContent: 'images',
+ autoSelect: me.insideWizard,
+ hidden: true,
+ disabled: true,
+ listeners: {
+ change: function(selector, selectedStorage) {
+ selector.next('pveFileSelector').setStorage(
+ selectedStorage,
+ me.getViewModel().get('nodename'),
+ );
+ },
+ },
+ }, {
+ xtype: 'pveFileSelector',
+ name: 'sourceVolid',
+ nodename: me.nodename,
+ storageContent: 'images',
+ hidden: true,
+ disabled: true,
+ fieldLabel: gettext('Image'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext("Place your source images into a new folder <storageRoot>/images/<newVMID>, for example /var/lib/vz/images/999"),
+ },
+ listeners: {
+ change: function(field, nowSelected) {
+ if (nowSelected) {
+ const size = Proxmox.Utils.format_size(this.getCurrentSize());
+ const targetSelector = this.up('pveQemuDiskBasic')
+ .down('pveDiskStorageSelector');
+
+ targetSelector.setSize(size);
+ targetSelector.down('#disksize').clearInvalid();
+ }
+ },
+ },
+ }, {
+ xtype: 'radiofield',
+ name: me.getRadioName(),
+ inputValue: 'path',
+ boxLabel: gettext('Use an absolute path as source'),
+ hidden: Proxmox.UserName !== 'root@pam',
+ listeners: {
+ change: (radiofield, nowSelected) => {
+ const field = radiofield.next('textfield[name=sourcePath]');
+ field.setHidden(!nowSelected);
+ field.setDisabled(!nowSelected);
+
+ const targetSelector = me.down('pveDiskStorageSelector');
+ if (nowSelected) {
+ targetSelector.setSize(0);
+ targetSelector.down('#disksize').clearInvalid();
+ }
+ },
+ enable: function() {
+ console.log('enable absolute path field');
+ },
+ disable: function() {
+ console.log('disable absolute path field');
+ },
+ },
+ }, {
+ xtype: 'textfield',
+ fieldLabel: gettext('Path'),
+ name: 'sourcePath',
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Absolute path to the source disk image, for example: /home/user/somedisk.qcow2'),
+ },
+ hidden: true,
+ disabled: true,
+ validator: function(insertedText) {
+ return insertedText.startsWith('/') ||
+ insertedText.startsWith('http') ||
+ gettext('Must be an absolute path or URL');
+ },
+ },
+ );
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/qemu/disk/DiskCollection.js b/www/manager6/qemu/disk/DiskCollection.js
new file mode 100644
index 00000000..11d39b46
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskCollection.js
@@ -0,0 +1,275 @@
+Ext.define('PVE.qemu.DiskCollection', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskCollection',
+
+ insideWizard: false,
+
+ hiddenDisks: [],
+
+ leftColumnRatio: 0.25,
+
+ column1: [
+ {
+ // Adding to the panelContainer below automatically adds
+ // items to the store
+ xtype: 'gridpanel',
+ scrollable: true,
+ width: 100,
+ height: 50,
+ store: {
+ xtype: 'store',
+ storeId: 'diskstorage',
+ // Use the panel as id
+ // Panels have are objects and therefore unique
+ // E.g. while adding new panels 'device' is ambiguous
+ fields: ['device', 'panel'],
+ removeByPanel: function(panel) {
+ const recordIndex = this.findBy(record => record.data.panel === panel);
+ this.removeAt(recordIndex);
+ return recordIndex;
+ },
+ getLast: function() {
+ const last = this.getCount() - 1;
+ return this.getAt(last);
+ },
+ },
+ columns: [
+ {
+ text: gettext('Device'),
+ dataIndex: 'device',
+ flex: 3,
+ resizable: false,
+ },
+ {
+ flex: 1,
+ xtype: 'actioncolumn',
+ align: 'center',
+ menuDisabled: true,
+ items: [
+ {
+ iconCls: 'x-fa fa-trash',
+ tooltip: 'Delete',
+ handler: function(button) {
+ button.up('pveQemuDiskCollection').removeCurrentDisk();
+ },
+ },
+ ],
+ },
+ ],
+ listeners: {
+ select: function(_, record) {
+ this.up('pveQemuDiskCollection')
+ .down('#panelContainer')
+ .setActiveItem(record.data.panel);
+ },
+ },
+ anchor: '100% 90%',
+ selectLast: function() {
+ this.setSelection(this.store.getLast());
+ },
+ dockedItems: [
+ {
+ xtype: 'toolbar',
+ dock: 'bottom',
+ ui: 'footer',
+ style: {
+ backgroundColor: 'transparent',
+ },
+ layout: {
+ pack: 'center',
+ },
+ items: [
+ {
+ iconCls: 'fa fa-plus-circle',
+ itemId: 'addDisk',
+ minWidth: '60',
+ handler: function(button) {
+ button.up('pveQemuDiskCollection').addDisk();
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ column2: [
+ {
+ itemId: 'panelContainer',
+ xtype: 'container',
+ layout: 'card',
+ items: [],
+ listeners: {
+ beforeRender: function() {
+ // Initial disk if none have been added by manifest yet
+ if (this.items.items.length === 0) {
+ this.addDisk();
+ }
+ },
+ add: function(container, newPanel) {
+ const store = Ext.getStore('diskstorage');
+ store.add({ device: newPanel.getDevice(), panel: newPanel });
+ container.setActiveItem(newPanel);
+ },
+ remove: function(panelContainer, panel, eOpts) {
+ const store = Ext.getStore('diskstorage');
+ store.removeByPanel(panel);
+ if (panelContainer.items.getCount() > 0) {
+ panelContainer.setActiveItem(0);
+ }
+ },
+ },
+ defaultItem: {
+ xtype: 'pveQemuHardDisk',
+ bind: {
+ nodename: '{nodename}',
+ },
+ listeners: {
+ // newPanel ... cloned + added defaultItem
+ added: function(newPanel) {
+ Ext.Array.each(newPanel.down('pveControllerSelector').query('field'),
+ function(field) {
+ //the fields don't exist earlier
+ field.on('change', function() {
+ const store = Ext.getStore('diskstorage');
+
+ // find by panel object because it is unique
+ const recordIndex = store.findBy(record =>
+ record.data.panel === field.up('pveQemuHardDisk'),
+ );
+ const controllerSelector = field.up('pveControllerSelector');
+ const newControllerAndId = controllerSelector.getValuesAsString();
+ store.getAt(recordIndex).set('device', newControllerAndId);
+ });
+ },
+ );
+ const wizard = this.up('pveQemuCreateWizard');
+ Ext.Array.each(this.query('field'), function(field) {
+ field.on('change', wizard.validcheck);
+ field.on('validitychange', wizard.validcheck);
+ });
+ },
+ },
+ validator: function() {
+ let valid = true;
+ const fields = this.query('field, fieldcontainer');
+ Ext.Array.each(fields, function(field) {
+ if (Ext.isFunction(field.isValid) && !field.isValid()) {
+ valid = false;
+ }
+ });
+ return valid;
+ },
+ },
+
+ // device ... device that the new disk should be assigned to, e.g. ide0, sata2
+ // path ... content of the textfield with source path
+ addDisk(device, path) {
+ const initialValues = this.up('window').getValues();
+ const item = Ext.clone(this.defaultItem);
+ item.insideWizard = this.insideWizard;
+ const added = this.add(item);
+ // values in the storage will be updated by listeners
+ if (path) {
+ // Need to explicitly deactivate when not rendered
+ added.down('radiofield[inputValue=empty]').setValue(false);
+ added.down('radiofield[inputValue=path]').setValue(true);
+ added.down('textfield[name=sourcePath]').setValue(path);
+ } else {
+ added.down('#empty').setValue(true);
+ }
+ const selector = added.down('pveControllerSelector');
+ if (device) {
+ selector.setValue(device);
+ } else {
+ selector.setVMConfig(initialValues);
+ }
+
+ return added;
+ },
+ removeCurrentDisk: function() {
+ const activePanel = this.getLayout().activeItem; // panel = disk
+ if (activePanel) {
+ this.remove(activePanel);
+ }
+ },
+ },
+ ],
+
+ addDisk: function(device, path) {
+ this.down('#panelContainer').addDisk(device, path);
+ this.down('gridpanel').selectLast();
+ },
+ removeCurrentDisk: function() {
+ this.down('#panelContainer').removeCurrentDisk();
+ },
+ removeAllDisks: function() {
+ const container = this.down('#panelContainer');
+ while (container.items.items.length > 0) {
+ container.removeCurrentDisk();
+ }
+ },
+
+ beforeRender: function() {
+ const me = this;
+ const leftColumnPanel = me.items.get(0).items.get(0); // not the gridpanel
+ leftColumnPanel.setFlex(me.leftColumnRatio);
+ // any other panel because this has no height yet
+ const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+ me.down('gridpanel').setHeight(panelHeight);
+ },
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.query('pveQemuHardDisk').forEach(p => p.setNodename(nodename));
+ },
+
+ listeners: {
+ afterrender: function() {
+ const store = Ext.getStore('diskstorage');
+ const first = store.getAt(0);
+ if (first) {
+ this.down('gridpanel').setSelection(first);
+ }
+ },
+ },
+
+ // values ... is optional
+ hasDuplicateDevices: function(values) {
+ if (!values) {
+ values = this.up('form').getValues();
+ }
+ if (!Array.isArray(values.controller)) {
+ return false;
+ }
+ for (let i = 0; i < values.controller.length - 1; i++) {
+ for (let j = i+1; j < values.controller.length; j++) {
+ if (
+ values.controller[i] === values.controller[j] &&
+ values.deviceid[i] === values.deviceid[j]
+ ) {
+ return true;
+ }
+ }
+ }
+ return false;
+ },
+
+ onGetValues: function(values) {
+ if (this.hasDuplicateDevices(values)) {
+ Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+ }
+ // Each child panel has sufficient onGetValues() => Return nothing
+ },
+
+ validator: function() {
+ const me = this;
+ const panels = me.down('#panelContainer').items.getRange();
+ return panels.every(panel => panel.validator()) && !me.hasDuplicateDevices();
+ },
+
+ initComponent: function() {
+ this.callParent();
+ this.down('tableview').markDirty = false;
+ this.down('#panelContainer').insideWizard = this.insideWizard;
+ },
+});
diff --git a/www/manager6/qemu/disk/DiskOptions.js b/www/manager6/qemu/disk/DiskOptions.js
new file mode 100644
index 00000000..cbd38e56
--- /dev/null
+++ b/www/manager6/qemu/disk/DiskOptions.js
@@ -0,0 +1,243 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.DiskOptions', {
+ extend: 'Proxmox.panel.InputPanel',
+ alias: 'widget.pveQemuDiskOptions',
+ onlineHelp: 'qm_hard_disk',
+
+ insideWizard: false,
+
+ unused: false, // ADD usused disk imaged
+
+ padding: '10 10 10 10',
+
+ vmconfig: {}, // used to select usused disks
+
+ viewModel: {},
+
+ /**
+ * All radiofields in pveQemuDiskCollection have the same scope
+ * Make name of radiofields unique for each disk panel
+ */
+ getRadioName() {
+ return 'radio_' + this.id;
+ },
+
+ onGetValues: function(values) {
+ let me = this;
+
+ let params = {};
+
+ const simpleValues = me.up('pveQemuHardDisk').down('pveQemuDiskBasic').getValues();
+ const confidArray = Object.entries(simpleValues).filter(([key, _]) => key !== "import_sources");
+ // confidArray contains 1 array of length 2, e.g. confidArray = [["sata1", "local:-1,format=qcow2"]]
+ const confid = confidArray.shift().shift();
+ me.drive.file = ''; // append to drive of simple panel
+
+ PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
+ PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
+ PVE.Utils.propertyStringSet(me.drive, values.ssd, 'ssd', 'on');
+ PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
+
+ let names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
+ Ext.Array.each(names, function(name) {
+ let burst_name = name + '_max';
+ PVE.Utils.propertyStringSet(me.drive, values[name], name);
+ PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
+ });
+
+ params[confid] = PVE.Parser.printQemuDrive(me.drive).replace(/^,/, "");
+
+ return params;
+ },
+
+ setVMConfig: function(vmconfig) {
+ let me = this;
+
+ me.vmconfig = vmconfig;
+
+ if (me.bussel) {
+ me.bussel.setVMConfig(vmconfig);
+ me.scsiController.setValue(vmconfig.scsihw);
+ }
+ if (me.unusedDisks) {
+ let disklist = [];
+ Ext.Object.each(vmconfig, function(key, value) {
+ if (key.match(/^unused\d+$/)) {
+ disklist.push([key, value]);
+ }
+ });
+ me.unusedDisks.store.loadData(disklist);
+ me.unusedDisks.setValue(me.confid);
+ }
+ },
+
+ setDrive: function(drive) {
+ let me = this;
+
+ me.drive = drive;
+
+ let values = {};
+ let match = drive.file.match(/^([^:]+):/);
+ if (match) {
+ values.hdstorage = match[1];
+ }
+
+ values.hdimage = drive.file;
+ values.backup = PVE.Parser.parseBoolean(drive.backup, 1);
+ values.noreplicate = !PVE.Parser.parseBoolean(drive.replicate, 1);
+ values.diskformat = drive.format || 'raw';
+ values.cache = drive.cache || '__default__';
+ values.discard = drive.discard === 'on';
+ values.ssd = PVE.Parser.parseBoolean(drive.ssd);
+ values.iothread = PVE.Parser.parseBoolean(drive.iothread);
+
+ values.mbps_rd = drive.mbps_rd;
+ values.mbps_wr = drive.mbps_wr;
+ values.iops_rd = drive.iops_rd;
+ values.iops_wr = drive.iops_wr;
+ values.mbps_rd_max = drive.mbps_rd_max;
+ values.mbps_wr_max = drive.mbps_wr_max;
+ values.iops_rd_max = drive.iops_rd_max;
+ values.iops_wr_max = drive.iops_wr_max;
+
+ me.setValues(values);
+ },
+
+
+ setNodename: function(nodename) {
+ // nothing
+ },
+
+ initComponent: function() {
+ let me = this;
+
+ let labelWidth = 140;
+
+ me.drive = {};
+
+ me.column1 = [];
+ me.column2 = [];
+
+ me.column1.push(
+ {
+ xtype: 'proxmoxcheckbox',
+ disabled: me.confid && me.confid.match(/^virtio/),
+ fieldLabel: gettext('SSD emulation'),
+ labelWidth: labelWidth,
+ name: 'ssd',
+ reference: 'ssd',
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ disabled: me.confid && !me.confid.match(/^(virtio|scsi)/),
+ fieldLabel: 'IO thread',
+ labelWidth: labelWidth,
+ reference: 'iothread',
+ name: 'iothread',
+ listeners: {
+ change: function(f, value) {
+ const disk = f.up('pveQemuHardDisk');
+ if (disk.insideWizard) {
+ const vmScsiType = value ? 'virtio-scsi-single' : 'virtio-scsi-pci';
+ disk.down('field[name=scsiController]').setValue(vmScsiType);
+ }
+ },
+ },
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_rd',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Read limit') + ' (MB/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_wr',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Write limit') + ' (MB/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_rd',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Read limit') + ' (ops/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_wr',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Write limit') + ' (ops/s)',
+ labelWidth: labelWidth,
+ emptyText: gettext('unlimited'),
+ },
+ );
+
+ me.column2.push(
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Backup'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Include volume in backup job'),
+ },
+ labelWidth: labelWidth,
+ name: 'backup',
+ value: me.isCreate,
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ fieldLabel: gettext('Skip replication'),
+ labelWidth: labelWidth,
+ name: 'noreplicate',
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_rd_max',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Read max burst') + ' (MB)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'numberfield',
+ name: 'mbps_wr_max',
+ minValue: 1,
+ step: 1,
+ fieldLabel: gettext('Write max burst') + ' (MB)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_rd_max',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Read max burst') + ' (ops)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ {
+ xtype: 'proxmoxintegerfield',
+ name: 'iops_wr_max',
+ minValue: 10,
+ step: 10,
+ fieldLabel: gettext('Write max burst') + ' (ops)',
+ labelWidth: labelWidth,
+ emptyText: gettext('default'),
+ },
+ );
+
+ me.callParent();
+ },
+});
diff --git a/www/manager6/qemu/disk/HardDisk.js b/www/manager6/qemu/disk/HardDisk.js
new file mode 100644
index 00000000..6fc8c55f
--- /dev/null
+++ b/www/manager6/qemu/disk/HardDisk.js
@@ -0,0 +1,137 @@
+/* 'change' property is assigned a string and then a function */
+Ext.define('PVE.qemu.HardDisk', {
+ extend: 'Ext.tab.Panel',
+ alias: 'widget.pveQemuHardDisk',
+ onlineHelp: 'qm_hard_disk',
+
+ tabPosition: 'bottom',
+ plain: true,
+
+ bind: {
+ nodename: '{nodename}',
+ },
+
+ insideWizard: false,
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.items.each(panel => panel.setNodename(nodename));
+ },
+
+ getDevice: function() {
+ return this.down('pveQemuDiskBasic').getDevice();
+ },
+
+ items: [
+ {
+ title: gettext('Basic'),
+ xtype: 'pveQemuDiskBasic',
+ isCreate: true,
+ bind: {
+ nodename: '{nodename}',
+ },
+ },
+ {
+ title: gettext('Options'),
+ xtype: 'pveQemuDiskOptions',
+ isCreate: true,
+ bind: {
+ nodename: '{nodename}',
+ },
+ },
+ ],
+
+ beforeRender: function() {
+ const me = this;
+ // any other panel because this has no height yet
+ if (me.insideWizard) {
+ const panelHeight = me.up('tabpanel').items.get(0).getHeight();
+ me.setHeight(panelHeight);
+ }
+ },
+ initComponent: function() {
+ const me = this;
+ me.items.forEach(i => { i.insideWizard = me.insideWizard; });
+ me.callParent();
+ },
+
+ setVMConfig: function(vmconfig) {
+ this.items.each(panel => panel.setVMConfig(vmconfig));
+ },
+ });
+
+Ext.define('PVE.qemu.HardDiskWindow', {
+ extend: 'Proxmox.window.Edit',
+
+ isAdd: true,
+
+ backgroundDelay: 5,
+
+ setNodename: function(nodename) {
+ this.nodename = nodename;
+ this.down('pveQemuHDTabpanel').setNodename(nodename);
+ },
+
+ initComponent: function() {
+ let me = this;
+
+ if (!me.nodename) {
+ throw "no node name specified";
+ }
+
+ let unused = me.confid && me.confid.match(/^unused\d+$/);
+
+ me.isCreate = me.confid ? unused : true;
+
+ let ipanel = Ext.create('PVE.qemu.HardDisk', {
+ confid: me.confid,
+ unused: unused,
+ isCreate: me.isCreate,
+ });
+ ipanel.setNodename(me.nodename);
+
+ if (unused) {
+ me.subject = gettext('Unused Disk');
+ } else if (me.isCreate) {
+ me.subject = gettext('Hard Disk');
+ } else {
+ me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
+ }
+
+ me.items = [ipanel];
+
+ me.callParent();
+ /* 'data' is assigned an empty array in same file, and here we
+ * use it like an object
+ */
+ me.load({
+ success: function(response, options) {
+ ipanel.setVMConfig(response.result.data);
+ if (me.confid) {
+ let value = response.result.data[me.confid];
+ let drive = PVE.Parser.parseQemuDrive(me.confid, value);
+ if (!drive) {
+ Ext.Msg.alert(gettext('Error'), 'Unable to parse drive options');
+ me.close();
+ return;
+ }
+ ipanel.setDrive(drive);
+ me.isValid(); // trigger validation
+ }
+ },
+ });
+ },
+ getValues: function() {
+ let values = this.callParent();
+ for (const [key, value] of Object.entries(values)) {
+ const re = /ide\d+|sata\d+|virtio\d+|scsi\d+|import_sources/;
+ if (key.match(re) && Array.isArray(value)) {
+ // Collected from different panels => array
+ // But API & some GUI functions expect not array
+ const sep = key === 'import_sources' ? '\0' : ',';
+ values[key] = value.join(sep);
+ }
+ }
+ return values;
+ },
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 47d60b8e..de935fd0 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -245,6 +245,8 @@ Ext.define('PVE.window.Wizard', {
};
field.on('change', validcheck);
field.on('validitychange', validcheck);
+ // Make available for fields that get added later
+ me.validcheck = validcheck;
});
},
});
--
2.30.2
^ permalink raw reply [flat|nested] 2+ messages in thread
end of thread, other threads:[~2021-06-10 10:21 UTC | newest]
Thread overview: 2+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2021-06-10 10:20 [pve-devel] [PATCH v9 qemu-server] Add API for VM import Dominic Jäger
2021-06-10 10:20 ` [pve-devel] [PATCH v9 manager] Add GUI to import disk & VM Dominic Jäger
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox