all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal