public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pve-devel] [PATCH 0/4] Importwizard
@ 2020-11-19  9:46 Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH qemu-server 1/4] Move importdisk from qm to API Dominic Jäger
                   ` (3 more replies)
  0 siblings, 4 replies; 6+ messages in thread
From: Dominic Jäger @ 2020-11-19  9:46 UTC (permalink / raw)
  To: pve-devel

As I've talked with Thomas & Dominik about the current state.
I cleaned up the worst chaos, but it's still very much work in progress.

Nonetheless, if
 - you have an .ovf
 - and its disks somewhere on your server
 - and are root
then you can click through the wizard and will have a VM
 - that actually starts
 - has all the disks automatically attached, converted etc.
 - has hostname, cores, RAM like in the .ovf
so that's something, I think.

Dominic Jäger (1):
  Move importdisk from qm to API

 PVE/API2/Qemu.pm             | 209 ++++++++++++++++++++++++++++++++++-
 PVE/API2/Qemu/Import.pm      |  47 ++++++++
 PVE/CLI/qm.pm                |  70 ++----------
 PVE/QemuServer.pm            |  18 ++-
 PVE/QemuServer/Drive.pm      |  20 ++++
 PVE/QemuServer/ImportDisk.pm |  85 --------------
 PVE/QemuServer/Makefile      |   1 -
 PVE/QemuServer/OVF.pm        |  10 +-
 8 files changed, 306 insertions(+), 154 deletions(-)
 create mode 100644 PVE/API2/Qemu/Import.pm
 delete mode 100755 PVE/QemuServer/ImportDisk.pm

Dominic Jäger (3):
 gui: Hardware View: Add GUI for importdisk
 gui: Add button & cmdmenu
 gui: Add importdisk wizard

 PVE/API2/Nodes.pm                       |  48 +++++++++++++++++
 www/manager6/Makefile                   |   2 +
 www/manager6/Workspace.js               |  15 ++++++
 www/manager6/form/ControllerSelector.js |  26 +++++----
 www/manager6/node/CmdMenu.js            |  12 +++++
 www/manager6/qemu/HDEdit.js             | 201 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
 www/manager6/qemu/HardwareView.js       |  24 +++++++++
 www/manager6/qemu/ImportWizard.js       | 379 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 www/manager6/qemu/MultiHDEdit.js        | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 www/manager6/window/Wizard.js           | 153 +++++++++++++++++++++++++++++------------------------
 10 files changed, 1032 insertions(+), 95 deletions(-)
-- 
2.20.1




^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH qemu-server 1/4] Move importdisk from qm to API
  2020-11-19  9:46 [pve-devel] [PATCH 0/4] Importwizard Dominic Jäger
@ 2020-11-19  9:46 ` Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk Dominic Jäger
                   ` (2 subsequent siblings)
  3 siblings, 0 replies; 6+ messages in thread
From: Dominic Jäger @ 2020-11-19  9:46 UTC (permalink / raw)
  To: pve-devel

Required to create a GUI for importdisk.

Add parameters that enable directly attaching the disk to a bus/device with all
known disk options. This avoids intermediate steps as unused disk.

We allow different places as source
* Regular VM images on PVE storages (Normal users + root)
* Other disk images on PVE storages (Normal users + root) (they have already
    been displayed in the FileSelector before)
* Any path (root only)

Using any path for normal users would be security risk. But if you have to
move a disk image to a PVE storage you only are not too many steps
* rename image according to PVE schema
* qm rescan
* double click in GUI to attach
away from making the whole importdisk obsolete.

Enabling arbitrary paths for root additionally makes it unnecessary to move the
disk image or create an appropriate storage. That means no knowledge about PVE
storage content naming schemes ("why do I have to move it into a images/<vmid>
subfolder of a directory based storage?") is required. Importing could then be
comprised of only two steps:
1. mount external drive (hopefully most PVE admins can figure this out)
2. Click through GUI window and insert /mount/externalDrive/exportedFromEsxi.vmdk

Uploading disk images to avoid the PVE storage naming knowledge can still be
added in the future. However, such a function might not be ideal for big images
because
* Upload via browser might fail easily?
* Potentially copying huge images from server to local to server?

So having the absolute path as an option between renaming everything manually
and just uploading it in GUI without CLI knowledge looks like a useful addition
to me.

This patch combines the main part of the previous qm importdisk and do_import
into a helper $convert_and_attach in PVE/API2/Qemu.pm to avoid race conditions
and potentially duplicating code from update_vm_api into do_import.
Furthermore, the only other place where it was invoked was importovf, which now
also uses the helper. importovf will be moved to PVE/API2/Qemu.pm, too, so
placing the helper somewhere else does not look useful to me.

Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
This already had some versions. Fabian G already left some feedback (e.g. permissions)
for the last one the mailing list which I haven't been able to address yet.

 PVE/API2/Qemu.pm             | 209 ++++++++++++++++++++++++++++++++++-
 PVE/API2/Qemu/Import.pm      |  47 ++++++++
 PVE/CLI/qm.pm                |  70 ++----------
 PVE/QemuServer.pm            |  18 ++-
 PVE/QemuServer/Drive.pm      |  20 ++++
 PVE/QemuServer/ImportDisk.pm |  85 --------------
 PVE/QemuServer/Makefile      |   1 -
 PVE/QemuServer/OVF.pm        |  10 +-
 8 files changed, 306 insertions(+), 154 deletions(-)
 create mode 100644 PVE/API2/Qemu/Import.pm
 delete mode 100755 PVE/QemuServer/ImportDisk.pm

diff --git a/PVE/API2/Qemu.pm b/PVE/API2/Qemu.pm
index bef83df..23f8ee6 100644
--- a/PVE/API2/Qemu.pm
+++ b/PVE/API2/Qemu.pm
@@ -45,8 +45,6 @@ BEGIN {
     }
 }
 
-use Data::Dumper; # fixme: remove
-
 use base qw(PVE::RESTHandler);
 
 my $opt_force_description = "Force physical removal. Without this, we simple remove the disk from the config file and create an additional configuration entry called 'unused[n]', which contains the volume ID. Unlink of unused[n] always cause physical removal.";
@@ -173,6 +171,28 @@ my $create_disks = sub {
 	    push @$vollist, $volid;
 	    delete $disk->{format}; # no longer needed
 	    $res->{$ds} = PVE::QemuServer::print_drive($disk);
+	} elsif ($disk->{importsource}) {
+	    # must be before $NEW_DISK_RE because $NEW_DISK_RE is matched in imports
+	    # because the "magic" number of the volid is irrelevant and arbitrarily set to 0 so the API allows it
+	    my $volid_as_path = eval { # Nonempty iff $original_source is a volid
+		PVE::Storage::path($storecfg, $disk->{importsource});
+	    };
+	    my $source_as_path = $volid_as_path ||  $disk->{importsource} ;
+	    my $volid = $PVE::API2::Qemu::convert_and_attach_disk->({
+		vmid => $vmid,
+		original_source => $disk->{importsource},
+		device => $disk->{interface} . $disk->{index},
+		device_options => "discard=on",
+		storage => (split(':', $disk->{file}))[0],
+		source_as_path => $source_as_path,
+		format => $disk->{format},
+		skiplock => 1,
+		}
+	    );
+	    delete $disk->{importsource};
+	    $disk->{file} = $volid;
+	    push @$vollist, $volid;
+	    $res->{$ds} =  PVE::QemuServer::print_drive($disk);
 	} elsif ($volid =~ $NEW_DISK_RE) {
 	    my ($storeid, $size) = ($2 || $default_storage, $3);
 	    die "no storage ID specified (and no default storage)\n" if !$storeid;
@@ -4289,4 +4309,189 @@ __PACKAGE__->register_method({
 	return PVE::QemuServer::Cloudinit::dump_cloudinit_config($conf, $param->{vmid}, $param->{type});
     }});
 
+# TODO Make locally scoped when importovf is moved from qm to API / this package
+our $convert_and_attach_disk = sub {
+    my ($param) = @_;
+
+    my $vm_conf = PVE::QemuConfig->load_config($param->{vmid});
+    my $store_conf = PVE::Storage::config();
+    PVE::QemuConfig->check_lock($vm_conf) if !$param->{skiplock};
+    if ($param->{device} && $vm_conf->{$param->{device}}) {
+	die "Could not import because device $param->{device} is already in ".
+	"use in VM $param->{vmid}. Choose a different device!\n";
+    }
+    if ($param->{digest} && $param->{digest} ne $vm_conf->{digest}) {
+	die "VM $param->{vmid} config checksum missmatch (file change by other user?)\n";
+    }
+
+    my $msg = $param->{device} ? "to $param->{device} on" : 'as unused disk to';
+    print "Importing disk '$param->{source_as_path}' $msg VM $param->{vmid}...\n";
+
+    my $src_size = PVE::Storage::file_size_info($param->{source_as_path});
+    my $dst_format = PVE::QemuServer::resolve_dst_disk_format(
+	$store_conf, $param->{storage}, undef, $param->{format});
+    my $dst_volid = PVE::Storage::vdisk_alloc($store_conf, $param->{storage},
+	$param->{vmid}, $dst_format, undef, $src_size / 1024);
+
+    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($store_conf,
+	    'sparseinit', $dst_volid);
+
+	PVE::Storage::activate_volumes($store_conf, [$dst_volid]);
+	PVE::QemuServer::qemu_img_convert($param->{source_as_path}, $dst_volid,
+	$src_size, undef, $zeroinit);
+	PVE::Storage::deactivate_volumes($store_conf, [$dst_volid]);
+
+	if ($param->{device}) {
+	    my $device_param = $dst_volid;
+	    $device_param .= ",$param->{device_options}" if $param->{device_options};
+	    print("before update api \n");
+	    $update_vm_api->({
+		vmid => $param->{vmid},
+		$param->{device} => $device_param,
+		skiplock => $param->{skiplock} || 0, # Avoid uninitialized values
+	    }, 1);
+	} else {
+	    $param->{device} = PVE::QemuConfig->add_unused_volume($vm_conf, $dst_volid);
+	    PVE::QemuConfig->write_config($param->{vmid}, $vm_conf);
+	}
+    };
+    if (my $err = $@) {
+	eval { PVE::Storage::vdisk_free($store_conf, $dst_volid) };
+	warn "Cleanup of $dst_volid failed: $@ \n" if $@;
+
+	die "Importing disk '$param->{source_as_path}' failed: $err\n" if $err;
+    }
+
+    return $dst_volid;
+};
+
+__PACKAGE__->register_method ({
+    name => 'importdisk',
+    path => '{vmid}/importdisk',
+    method => 'POST',
+    protected => 1, # for worker upid file
+    proxyto => 'node',
+    description => "Import an external disk image into a VM. The image format ".
+	"has to be supported by qemu-img.",
+    permissions => {
+	check => [ 'and',
+	    [ 'perm', '/storage/{storage}', ['Datastore.Audit']],
+	    [ 'perm', '/storage/{storage}', ['Datastore.Allocate']],
+	    [ 'perm', '/storage/{storage}', ['Datastore.AllocateTemplate']],
+	    [ 'perm', '/storage/{storage}', ['Datastore.AllocateSpace']],
+	    [ 'perm', '/vms/{vmid}', ['VM.Allocate']],
+	    [ 'perm', '/vms/{vmid}', ['VM.Config.Disk']],
+	],
+    },
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    node => get_standard_option('pve-node'),
+	    vmid => get_standard_option('pve-vmid',
+		{completion => \&PVE::QemuServer::complete_vmid}),
+	    source => {
+		description => "Disk image to import. Can be a volid ".
+		    "(local-lvm:vm-104-disk-0), an image on a PVE storage ".
+		    "(local:104/toImport.raw) or (for root only) an absolute ".
+		    "path on the server.",
+		type => 'string',
+	    },
+	    device => {
+		type => 'string',
+		description => "Bus/Device type of the new disk (e.g. 'ide0', ".
+		    "'scsi2'). Will add the image as unused disk if omitted.",
+		enum => [PVE::QemuServer::Drive::valid_drive_names()],
+		optional => 1,
+	    },
+	    device_options => {
+		type => 'string',
+		format => 'drive_options',
+		description => "Options to set for the new disk ".
+		    "(e.g. 'discard=on,backup=0')",
+		optional => 1,
+	    },
+	    storage => get_standard_option('pve-storage-id', {
+		description => "The storage to which the image will be imported to.",
+		completion => \&PVE::QemuServer::complete_storage,
+	    }),
+	    format => {
+		type => 'string',
+		description => 'Target format.',
+		enum => [ 'raw', 'qcow2', 'vmdk' ],
+		optional => 1,
+	    },
+	    digest => get_standard_option('pve-config-digest'),
+	    skiplock => get_standard_option('skiplock'),
+	},
+    },
+    returns => { type => 'string'},
+    code => sub {
+	my ($param) = @_;
+	my $rpcenv = PVE::RPCEnvironment::get();
+	my $authuser = $rpcenv->get_user();
+
+	my $vmid = extract_param($param, 'vmid');
+	my $original_source = extract_param($param, 'source');
+	my $digest = extract_param($param, 'digest');
+	my $device_options = extract_param($param, 'device_options');
+	my $device = extract_param($param, 'device');
+	# importovf holds a lock itself which would make automatically updating
+	# VM configs fail
+	my $skiplock = extract_param($param, 'skiplock');
+	my $storecfg = PVE::Storage::config();
+
+	if ($skiplock && $authuser ne 'root@pam') {
+	    raise_perm_exc("Only root may use skiplock.");
+	}
+	if ($original_source eq "") {
+	    die "Could not import because source parameter is an empty string!\n";
+	}
+	if ($device && !PVE::QemuServer::is_valid_drivename($device)) {
+	    die "Invalid device name: $device!";
+	}
+	if ($device_options && !$device) {
+	    die "Cannot use --device_options without specifying --device!"
+	}
+	eval {
+	    PVE::Storage::check_volume_access($rpcenv, $authuser, $storecfg,
+		$vmid, $original_source)
+	};
+	raise_perm_exc($@) if $@;
+
+	# A path is required for $convert_and_attach_disk
+	my $volid_as_path = eval { # Nonempty iff $original_source is a volid
+	    PVE::Storage::path($storecfg, $original_source);
+	};
+	my $source_as_path = $volid_as_path || $original_source ;
+	if (!-e $source_as_path) {
+	    die "Could not import because source '$original_source' does not exist!\n";
+	}
+
+	my $worker = sub {
+	    print("before lock config in importdisk\n");
+	    my $dst_volid = PVE::QemuConfig->lock_config($vmid, $convert_and_attach_disk,
+	    {
+		vmid => $vmid,
+		original_source => $original_source,
+		device => $device,
+		device_options => $device_options,
+		storage => extract_param($param, 'storage'),
+		source_as_path => $source_as_path,
+		format => extract_param($param, 'format'),
+		skiplock => $skiplock,
+	    });
+	    print "Successfully imported disk '$original_source ' as ".
+		"$device: $dst_volid\n";
+	};
+
+	return $rpcenv->fork_worker('importdisk', $vmid, $authuser, $worker);
+    }});
+
 1;
diff --git a/PVE/API2/Qemu/Import.pm b/PVE/API2/Qemu/Import.pm
new file mode 100644
index 0000000..8f6bbf6
--- /dev/null
+++ b/PVE/API2/Qemu/Import.pm
@@ -0,0 +1,47 @@
+package PVE::API2::Qemu::Import;
+
+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',
+    protected => 1, # for worker upid file
+    proxyto => 'node',
+    description => "Read an .ovf manifest.",
+    parameters => {
+	additionalProperties => 0,
+	properties => {
+	    manifest => {
+		description => ".ovf manifest",
+		type => 'string',
+	    },
+	},
+    },
+    returns => {
+	description => "The parsed .ovf manifest",
+	type => "object",
+	properties => PVE::QemuServer::json_config_properties({
+	    digest => {
+		type => 'string',
+		description => 'SHA1 digest of configuration file. This can be used to prevent concurrent modifications.',
+	    },
+	}),
+    },
+    code => sub {
+	my ($param) = @_;
+	my $manifest = extract_param($param, 'manifest');
+	my $parsed = PVE::QemuServer::OVF::parse_ovf($ovf_file);
+
+	return undef;
+}});
+
+
+1;
diff --git a/PVE/CLI/qm.pm b/PVE/CLI/qm.pm
index b3b9251..a19da6f 100755
--- a/PVE/CLI/qm.pm
+++ b/PVE/CLI/qm.pm
@@ -31,7 +31,6 @@ use PVE::QemuConfig;
 use PVE::QemuServer::Drive;
 use PVE::QemuServer::Helpers;
 use PVE::QemuServer::Agent qw(agent_available);
-use PVE::QemuServer::ImportDisk;
 use PVE::QemuServer::Monitor qw(mon_cmd);
 use PVE::QemuServer::OVF;
 use PVE::QemuServer;
@@ -440,61 +439,6 @@ __PACKAGE__->register_method ({
 	return;
     }});
 
-__PACKAGE__->register_method ({
-    name => 'importdisk',
-    path => 'importdisk',
-    method => 'POST',
-    description => "Import an external disk image as an unused disk in a VM. The
- image format has to be supported by qemu-img(1).",
-    parameters => {
-	additionalProperties => 0,
-	properties => {
-	    vmid => get_standard_option('pve-vmid', {completion => \&PVE::QemuServer::complete_vmid}),
-	    source => {
-		description => 'Path to the disk image to import',
-		type => 'string',
-		optional => 0,
-	    },
-            storage => get_standard_option('pve-storage-id', {
-		description => 'Target storage ID',
-		completion => \&PVE::QemuServer::complete_storage,
-		optional => 0,
-            }),
-	    format => {
-		type => 'string',
-		description => 'Target format',
-		enum => [ 'raw', 'qcow2', 'vmdk' ],
-		optional => 1,
-	    },
-	},
-    },
-    returns => { type => 'null'},
-    code => sub {
-	my ($param) = @_;
-
-	my $vmid = extract_param($param, 'vmid');
-	my $source = extract_param($param, 'source');
-	my $storeid = extract_param($param, 'storage');
-	my $format = extract_param($param, 'format');
-
-	my $vm_conf = PVE::QemuConfig->load_config($vmid);
-	PVE::QemuConfig->check_lock($vm_conf);
-	die "$source: non-existent or non-regular file\n" if (! -f $source);
-
-	my $storecfg = PVE::Storage::config();
-	PVE::Storage::storage_check_enabled($storecfg, $storeid);
-
-	my $target_storage_config =  PVE::Storage::storage_config($storecfg, $storeid);
-	die "storage $storeid does not support vm images\n"
-	    if !$target_storage_config->{content}->{images};
-
-	print "importing disk '$source' to VM $vmid ...\n";
-	my ($drive_id, $volid) = PVE::QemuServer::ImportDisk::do_import($source, $vmid, $storeid, { format => $format });
-	print "Successfully imported disk as '$drive_id:$volid'\n";
-
-	return;
-    }});
-
 __PACKAGE__->register_method ({
     name => 'terminal',
     path => 'terminal',
@@ -635,17 +579,21 @@ __PACKAGE__->register_method ({
 	$conf->{cores} = $parsed->{qm}->{cores} if defined($parsed->{qm}->{cores});
 
 	eval {
-	    # order matters, as do_import() will load_config() internally
+	    # order matters, as $convert_and_attach_disk will load_config() internally
 	    $conf->{vmgenid} = PVE::QemuServer::generate_uuid();
 	    $conf->{smbios1} = PVE::QemuServer::generate_smbios1_uuid();
 	    PVE::QemuConfig->write_config($vmid, $conf);
 
 	    foreach my $disk (@{ $parsed->{disks} }) {
 		my ($file, $drive) = ($disk->{backing_file}, $disk->{disk_address});
-		PVE::QemuServer::ImportDisk::do_import($file, $vmid, $storeid, {
-		    drive_name => $drive,
+		$PVE::API2::Qemu::convert_and_attach_disk->({
+		    node => $nodename,
+		    vmid => $vmid,
+		    source_as_path => $file,
+		    storage => $storeid,
+		    device => $drive,
 		    format => $format,
-		    skiplock => 1,
+		    skiplock => 1, # Required to update VM configs
 		});
 	    }
 
@@ -979,7 +927,7 @@ our $cmddef = {
 
     terminal => [ __PACKAGE__, 'terminal', ['vmid']],
 
-    importdisk => [ __PACKAGE__, 'importdisk', ['vmid', 'source', 'storage']],
+    importdisk => [ "PVE::API2::Qemu", 'importdisk', ['vmid', 'source', 'storage'], { node => $nodename }],
 
     importovf => [ __PACKAGE__, 'importovf', ['vmid', 'manifest', 'storage']],
 
diff --git a/PVE/QemuServer.pm b/PVE/QemuServer.pm
index 34b54d3..2e651e3 100644
--- a/PVE/QemuServer.pm
+++ b/PVE/QemuServer.pm
@@ -1003,6 +1003,22 @@ sub verify_volume_id_or_qm_path {
     return $volid;
 }
 
+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) = @_;
+
+    if ($volid =~ m|^/|) {
+	return $volid;
+    }
+
+    $volid = eval { PVE::JSONSchema::check_format('pve-volume-id', $volid, '') };
+    if ($@) {
+	return undef if $noerr;
+	die $@;
+    }
+    return $volid;
+}
+
 my $usb_fmt = {
     host => {
 	default_key => 1,
@@ -6639,7 +6655,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 _) { # -b for LVM images for example
 	$src_path = $src_volid;
 	if ($src_path =~ m/\.($PVE::QemuServer::Drive::QEMU_FORMAT_RE)$/) {
 	    $src_format = $1;
diff --git a/PVE/QemuServer/Drive.pm b/PVE/QemuServer/Drive.pm
index d560937..c2bc33b 100644
--- a/PVE/QemuServer/Drive.pm
+++ b/PVE/QemuServer/Drive.pm
@@ -145,6 +145,13 @@ my %drivedesc_base = (
 	verbose_description => "Mark this locally-managed volume as available on all nodes.\n\nWARNING: This option does not share the volume automatically, it assumes it is shared already!",
 	optional => 1,
 	default => 0,
+    },
+    importsource => {
+	type => 'string',
+	format => 'pve-volume-id-or-absolute-path',
+	format_description => 'Absolute path or volid',
+	description => 'Source to import the disk',
+	optional => 1,
     }
 );
 
@@ -308,6 +315,19 @@ my $alldrive_fmt = {
     %wwn_fmt,
 };
 
+my %optional_file_drivedesc_base = %drivedesc_base;
+$optional_file_drivedesc_base{file}{optional} = 1;
+my $drive_options_fmt = {
+    %optional_file_drivedesc_base,
+    %iothread_fmt,
+    %model_fmt,
+    %queues_fmt,
+    %scsiblock_fmt,
+    %ssd_fmt,
+    %wwn_fmt,
+};
+PVE::JSONSchema::register_format('drive_options', $drive_options_fmt);
+
 my $efidisk_fmt = {
     volume => { alias => 'file' },
     file => {
diff --git a/PVE/QemuServer/ImportDisk.pm b/PVE/QemuServer/ImportDisk.pm
deleted file mode 100755
index 51ad52e..0000000
--- a/PVE/QemuServer/ImportDisk.pm
+++ /dev/null
@@ -1,85 +0,0 @@
-package PVE::QemuServer::ImportDisk;
-
-use strict;
-use warnings;
-
-use PVE::Storage;
-use PVE::QemuServer;
-use PVE::Tools qw(run_command extract_param);
-
-# imports an external disk image to an existing VM
-# and creates by default a drive entry unused[n] pointing to the created volume
-# $params->{drive_name} may be used to specify ide0, scsi1, etc ...
-# $params->{format} may be used to specify qcow2, raw, etc ...
-sub do_import {
-    my ($src_path, $vmid, $storage_id, $params) = @_;
-
-    my $drive_name = extract_param($params, 'drive_name');
-    my $format = extract_param($params, 'format');
-    if ($drive_name && !(PVE::QemuServer::is_valid_drivename($drive_name))) {
-	die "invalid drive name: $drive_name\n";
-    }
-
-    # get the needed size from  source disk
-    my $src_size = PVE::Storage::file_size_info($src_path);
-
-    # get target format, target image's path, and whether it's possible to sparseinit
-    my $storecfg = PVE::Storage::config();
-    my $dst_format = PVE::QemuServer::resolve_dst_disk_format($storecfg, $storage_id, undef, $format);
-
-    my $dst_volid = PVE::Storage::vdisk_alloc($storecfg, $storage_id, $vmid, $dst_format, undef, $src_size / 1024);
-
-    my $zeroinit = PVE::Storage::volume_has_feature($storecfg, 'sparseinit', $dst_volid);
-
-    my $create_drive = sub {
-	my $vm_conf = PVE::QemuConfig->load_config($vmid);
-	if (!$params->{skiplock}) {
-	    PVE::QemuConfig->check_lock($vm_conf);
-	}
-
-	if ($drive_name) {
-	    # should never happen as setting $drive_name is not exposed to public interface
-	    die "cowardly refusing to overwrite existing entry: $drive_name\n" if $vm_conf->{$drive_name};
-
-	    my $modified = {}; # record what $option we modify
-	    $modified->{$drive_name} = 1;
-	    $vm_conf->{pending}->{$drive_name} = $dst_volid;
-	    PVE::QemuConfig->write_config($vmid, $vm_conf);
-
-	    my $running = PVE::QemuServer::check_running($vmid);
-	    if ($running) {
-		my $errors = {};
-		PVE::QemuServer::vmconfig_hotplug_pending($vmid, $vm_conf, $storecfg, $modified, $errors);
-		warn "hotplugging imported disk '$_' failed: $errors->{$_}\n" for keys %$errors;
-	    } else {
-		PVE::QemuServer::vmconfig_apply_pending($vmid, $vm_conf, $storecfg);
-	    }
-	} else {
-	    $drive_name = PVE::QemuConfig->add_unused_volume($vm_conf, $dst_volid);
-	    PVE::QemuConfig->write_config($vmid, $vm_conf);
-	}
-    };
-
-    eval {
-	# trap interrupts so we have a chance to clean up
-	local $SIG{INT} =
-	    local $SIG{TERM} =
-	    local $SIG{QUIT} =
-	    local $SIG{HUP} =
-	    local $SIG{PIPE} = sub { die "interrupted by signal $!\n"; };
-
-	PVE::Storage::activate_volumes($storecfg, [$dst_volid]);
-	PVE::QemuServer::qemu_img_convert($src_path, $dst_volid, $src_size, undef, $zeroinit);
-	PVE::Storage::deactivate_volumes($storecfg, [$dst_volid]);
-	PVE::QemuConfig->lock_config($vmid, $create_drive);
-    };
-    if (my $err = $@) {
-	eval { PVE::Storage::vdisk_free($storecfg, $dst_volid) };
-	warn "cleanup of $dst_volid failed: $@\n" if $@;
-	die $err;
-    }
-
-    return ($drive_name, $dst_volid);
-}
-
-1;
diff --git a/PVE/QemuServer/Makefile b/PVE/QemuServer/Makefile
index fd8cfbb..ecdab56 100644
--- a/PVE/QemuServer/Makefile
+++ b/PVE/QemuServer/Makefile
@@ -1,7 +1,6 @@
 SOURCES=PCI.pm		\
 	USB.pm		\
 	Memory.pm	\
-	ImportDisk.pm	\
 	OVF.pm		\
 	Cloudinit.pm	\
 	Agent.pm	\
diff --git a/PVE/QemuServer/OVF.pm b/PVE/QemuServer/OVF.pm
index c76c199..36b7fff 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, $ignore_size) = @_;
 
     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 = 0;
+	if (!$ignore_size) { # 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.20.1




^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk
  2020-11-19  9:46 [pve-devel] [PATCH 0/4] Importwizard Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH qemu-server 1/4] Move importdisk from qm to API Dominic Jäger
@ 2020-11-19  9:46 ` Dominic Jäger
  2020-11-20  8:15   ` Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 3/4] gui: Add button & cmdmenu Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 4/4] gui: Add importdisk wizard Dominic Jäger
  3 siblings, 1 reply; 6+ messages in thread
From: Dominic Jäger @ 2020-11-19  9:46 UTC (permalink / raw)
  To: pve-devel

Make importing single disks easier.
Required to import a whole VM via GUI.

Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
 www/manager6/qemu/HDEdit.js       | 134 ++++++++++++++++++++++++++----
 www/manager6/qemu/HardwareView.js |  24 ++++++
 2 files changed, 141 insertions(+), 17 deletions(-)

diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
index e2a5b914..5e0a3981 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -67,7 +67,8 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	if (me.unused) {
 	    me.drive.file = me.vmconfig[values.unusedId];
 	    confid = values.controller + values.deviceid;
-	} else if (me.isCreate) {
+	} else if (me.isCreate && !me.isImport) {
+	    // disk format & size should not be part of propertyString for import
 	    if (values.hdimage) {
 		me.drive.file = values.hdimage;
 	    } else {
@@ -83,16 +84,22 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
 	PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
 
-        var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
-        Ext.Array.each(names, function(name) {
-            var burst_name = name + '_max';
+	var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
+	Ext.Array.each(names, function(name) {
+	    var 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);
-
+	});
+	if (me.isImport) {
+	    params.device_options = PVE.Parser.printPropertyString(me.drive);
+	    params.source = values.sourceType === 'storage'
+		? values.sourceVolid : values.sourcePath;
+	    params.device = values.controller + values.deviceid;
+	    params.storage = values.hdstorage;
+	    if (values.diskformat) params.format = values.diskformat;
+	} else {
+	    params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	}
 	return params;
     },
 
@@ -169,10 +176,14 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	me.advancedColumn2 = [];
 
 	if (!me.confid || me.unused) {
+	    let controllerColumn = me.isImport ? me.column2 : me.column1;
 	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
 		vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {}
 	    });
-	    me.column1.push(me.bussel);
+	    if (me.isImport) {
+		me.bussel.fieldLabel = 'Target Device';
+	    }
+	    controllerColumn.push(me.bussel);
 
 	    me.scsiController = Ext.create('Ext.form.field.Display', {
 		fieldLabel: gettext('SCSI Controller'),
@@ -184,7 +195,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		submitValue: false,
 		hidden: true
 	    });
-	    me.column1.push(me.scsiController);
+	    controllerColumn.push(me.scsiController);
 	}
 
 	if (me.unused) {
@@ -199,14 +210,21 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		allowBlank: false
 	    });
 	    me.column1.push(me.unusedDisks);
-	} else if (me.isCreate) {
-	    me.column1.push({
+	} else if (me.isCreate || me.isImport) {
+	    let selector = {
 		xtype: 'pveDiskStorageSelector',
 		storageContent: 'images',
 		name: 'disk',
 		nodename: me.nodename,
-		autoSelect: me.insideWizard
-	    });
+		hideSize: me.isImport,
+		autoSelect: me.insideWizard || me.isImport,
+	    };
+	    if (me.isImport) {
+		selector.storageLabel = gettext('Target storage');
+		me.column2.push(selector);
+	    } else {
+		me.column1.push(selector);
+	    }
 	} else {
 	    me.column1.push({
 		xtype: 'textfield',
@@ -217,6 +235,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    });
 	}
 
+	if (me.isImport) {
+	    me.column2.push({
+		xtype: 'box',
+		autoEl: { tag: 'hr' },
+	    });
+	}
 	me.column2.push(
 	    {
 		xtype: 'CacheTypeSelector',
@@ -231,6 +255,74 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		name: 'discard'
 	    }
 	);
+	if (me.isImport) {
+	    let show = (element, value) => {
+		element.setHidden(!value);
+		element.setDisabled(!value);
+	    };
+	    me.sourceRadioStorage = Ext.create('Ext.form.field.Radio', {
+		name: 'sourceType',
+		inputValue: 'storage',
+		boxLabel: gettext('Use a storage as source'),
+		checked: true,
+		hidden: Proxmox.UserName !== 'root@pam',
+		listeners: {
+		    added: () => show(me.sourcePathTextfield, false),
+		    change: (_, storageRadioChecked) => {
+			show(me.sourcePathTextfield, !storageRadioChecked);
+			let selectors = [
+			    me.sourceStorageSelector,
+			    me.sourceFileSelector,
+			];
+			for (const selector of selectors) {
+			    show(selector, storageRadioChecked);
+			}
+		    },
+		},
+	    });
+	    me.sourceStorageSelector = Ext.create('PVE.form.StorageSelector', {
+		name: 'inputImageStorage',
+		nodename: me.nodename,
+		fieldLabel: gettext('Source Storage'),
+		storageContent: 'images',
+		autoSelect: me.insideWizard,
+		listeners: {
+		    change: function(_, selectedStorage) {
+			me.sourceFileSelector.setStorage(selectedStorage);
+		    },
+		},
+	    });
+	    me.sourceFileSelector = Ext.create('PVE.form.FileSelector', {
+		name: 'sourceVolid',
+		nodename: me.nodename,
+		storageContent: 'images',
+		fieldLabel: gettext('Source Image'),
+	    });
+	    me.sourceRadioPath = Ext.create('Ext.form.field.Radio', {
+		name: 'sourceType',
+		inputValue: 'path',
+		boxLabel: gettext('Use an absolute path as source'),
+		hidden: Proxmox.UserName !== 'root@pam',
+	    });
+	    me.sourcePathTextfield = Ext.create('Ext.form.field.Text', {
+		xtype: 'textfield',
+		fieldLabel: gettext('Source Path'),
+		name: 'sourcePath',
+		emptyText: '/home/user/disk.qcow2',
+		hidden: Proxmox.UserName !== 'root@pam',
+		validator: function(insertedText) {
+		    return insertedText.startsWith('/') ||
+			gettext('Must be an absolute path');
+		},
+	    });
+	    me.column1.unshift(
+		me.sourceRadioStorage,
+		me.sourceStorageSelector,
+		me.sourceFileSelector,
+		me.sourceRadioPath,
+		me.sourcePathTextfield,
+	    );
+	}
 
 	me.advancedColumn1.push(
 	    {
@@ -372,14 +464,19 @@ Ext.define('PVE.qemu.HDEdit', {
 	    confid: me.confid,
 	    nodename: nodename,
 	    unused: unused,
-	    isCreate: me.isCreate
+	    isCreate: me.isCreate,
+	    isImport: me.isImport,
 	});
 
 	var subject;
 	if (unused) {
 	    me.subject = gettext('Unused Disk');
+	} else if (me.isImport) {
+	    me.subject = gettext('Import Disk');
+	    me.submitText = 'Import';
+	    me.backgroundDelay = undefined;
 	} else if (me.isCreate) {
-            me.subject = gettext('Hard Disk');
+	    me.subject = gettext('Hard Disk');
 	} else {
            me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
 	}
@@ -404,6 +501,9 @@ Ext.define('PVE.qemu.HDEdit', {
 		    ipanel.setDrive(drive);
 		    me.isValid(); // trigger validation
 		}
+		if (me.isImport) {
+		    me.url = me.url.replace(/\/config$/, "/importdisk");
+		}
 	    }
 	});
     }
diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
index 40b3fe86..dc5e217e 100644
--- a/www/manager6/qemu/HardwareView.js
+++ b/www/manager6/qemu/HardwareView.js
@@ -436,6 +436,29 @@ Ext.define('PVE.qemu.HardwareView', {
 	    handler: run_move
 	});
 
+	var import_btn = new Proxmox.button.Button({
+	    text: gettext('Import disk'),
+	    hidden: !(
+		caps.storage['Datastore.Audit'] &&
+		caps.storage['Datastore.Allocate'] &&
+		caps.storage['Datastore.AllocateTemplate'] &&
+		caps.storage['Datastore.AllocateSpace'] &&
+		caps.vms['VM.Allocate'] &&
+		caps.vms['VM.Config.Disk'] &&
+		true
+	    ),
+	    handler: function() {
+		var win = Ext.create('PVE.qemu.HDEdit', {
+		    method: 'POST',
+		    url: `/api2/extjs/${baseurl}`,
+		    pveSelNode: me.pveSelNode,
+		    isImport: true,
+		});
+		win.on('destroy', me.reload, me);
+		win.show();
+	    },
+	});
+
 	var remove_btn = new Proxmox.button.Button({
 	    text: gettext('Remove'),
 	    defaultText: gettext('Remove'),
@@ -752,6 +775,7 @@ Ext.define('PVE.qemu.HardwareView', {
 		edit_btn,
 		resize_btn,
 		move_btn,
+		import_btn,
 		revert_btn
 	    ],
 	    rows: rows,
-- 
2.20.1




^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH manager 3/4] gui: Add button & cmdmenu
  2020-11-19  9:46 [pve-devel] [PATCH 0/4] Importwizard Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH qemu-server 1/4] Move importdisk from qm to API Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk Dominic Jäger
@ 2020-11-19  9:46 ` Dominic Jäger
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 4/4] gui: Add importdisk wizard Dominic Jäger
  3 siblings, 0 replies; 6+ messages in thread
From: Dominic Jäger @ 2020-11-19  9:46 UTC (permalink / raw)
  To: pve-devel

---
 www/manager6/Workspace.js    | 15 +++++++++++++++
 www/manager6/node/CmdMenu.js | 12 ++++++++++++
 2 files changed, 27 insertions(+)

diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js
index 3d0f3fec..6ee7c957 100644
--- a/www/manager6/Workspace.js
+++ b/www/manager6/Workspace.js
@@ -282,11 +282,25 @@ Ext.define('PVE.StdWorkspace', {
 	    }
 	});
 
+	var importVM = Ext.createWidget('button', {
+	    pack: 'end',
+	    margin: '3 5 0 0',
+	    baseCls: 'x-btn',
+	    iconCls: 'fa fa-desktop',
+	    text: gettext("Import VM"),
+	    disabled: !caps.vms['VM.Allocate'],
+	    handler: function() {
+		var wiz = Ext.create('PVE.qemu.ImportWizard', {});
+		wiz.show();
+	    }
+	});
+
 	sprovider.on('statechange', function(sp, key, value) {
 	    if (key === 'GuiCap' && value) {
 		caps = value;
 		createVM.setDisabled(!caps.vms['VM.Allocate']);
 		createCT.setDisabled(!caps.vms['VM.Allocate']);
+		importVM.setDisabled(!caps.vms['VM.Allocate']);
 	    }
 	});
 
@@ -334,6 +348,7 @@ Ext.define('PVE.StdWorkspace', {
 			},
 			createVM,
 			createCT,
+			importVM,
 			{
 			    pack: 'end',
 			    margin: '0 5 0 0',
diff --git a/www/manager6/node/CmdMenu.js b/www/manager6/node/CmdMenu.js
index f718f69a..9bc27155 100644
--- a/www/manager6/node/CmdMenu.js
+++ b/www/manager6/node/CmdMenu.js
@@ -29,6 +29,18 @@ Ext.define('PVE.node.CmdMenu', {
 		wiz.show();
 	    }
 	},
+	{
+	    text: gettext("Import VM"),
+	    itemId: 'importvm',
+	    iconCls: 'fa fa-cube',
+	    handler: function() {
+		var me = this.up('menu');
+		var wiz = Ext.create('PVE.qemu.ImportWizard', {
+		    nodename: me.nodename
+		});
+		wiz.show();
+	    }
+	},
 	{ xtype: 'menuseparator' },
 	{
 	    text: gettext('Bulk Start'),
-- 
2.20.1




^ permalink raw reply	[flat|nested] 6+ messages in thread

* [pve-devel] [PATCH manager 4/4] gui: Add importdisk wizard
  2020-11-19  9:46 [pve-devel] [PATCH 0/4] Importwizard Dominic Jäger
                   ` (2 preceding siblings ...)
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 3/4] gui: Add button & cmdmenu Dominic Jäger
@ 2020-11-19  9:46 ` Dominic Jäger
  3 siblings, 0 replies; 6+ messages in thread
From: Dominic Jäger @ 2020-11-19  9:46 UTC (permalink / raw)
  To: pve-devel

Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
---
Somehow selecting storages is not possible anymore. It was at some point.
You can add disks that are not in the OVF in the importwizard at the moment.

 PVE/API2/Nodes.pm                       |  48 +++
 www/manager6/Makefile                   |   2 +
 www/manager6/form/ControllerSelector.js |  26 +-
 www/manager6/qemu/HDEdit.js             | 219 +++++++++-----
 www/manager6/qemu/ImportWizard.js       | 379 ++++++++++++++++++++++++
 www/manager6/qemu/MultiHDEdit.js        | 267 +++++++++++++++++
 www/manager6/window/Wizard.js           | 153 +++++-----
 7 files changed, 940 insertions(+), 154 deletions(-)
 create mode 100644 www/manager6/qemu/ImportWizard.js
 create mode 100644 www/manager6/qemu/MultiHDEdit.js

diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm
index 1b133352..b0e386f9 100644
--- a/PVE/API2/Nodes.pm
+++ b/PVE/API2/Nodes.pm
@@ -27,6 +27,7 @@ use PVE::HA::Env::PVE2;
 use PVE::HA::Config;
 use PVE::QemuConfig;
 use PVE::QemuServer;
+use PVE::QemuServer::OVF;
 use PVE::API2::Subscription;
 use PVE::API2::Services;
 use PVE::API2::Network;
@@ -224,6 +225,7 @@ __PACKAGE__->register_method ({
 	    { name => 'subscription' },
 	    { name => 'report' },
 	    { name => 'tasks' },
+	    { name => 'readovf' },
 	    { name => 'rrd' }, # fixme: remove?
 	    { name => 'rrddata' },# fixme: remove?
 	    { name => 'replication' },
@@ -2137,6 +2139,52 @@ __PACKAGE__->register_method ({
 	return undef;
     }});
 
+__PACKAGE__->register_method ({
+    name => 'readovf',
+    path => 'readovf',
+    method => 'GET',
+    protected => 1, # for worker upid file
+    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",
+	properties => PVE::QemuServer::json_config_properties({
+	    digest => {
+		type => 'string',
+		description => 'SHA1 digest of configuration file. This can be used to prevent concurrent modifications.',
+	    },
+	}),
+    },
+    code => sub {
+	my ($param) = @_;
+
+	my $filename = '/tmp/readovflog';
+	open (my $fh, '>', $filename) or die "could not open file $filename";
+	my $parsed = PVE::QemuServer::OVF::parse_ovf($param->{manifest}, 1, 1);
+	my $result;
+	$result->{digest} = Digest::SHA::sha1_hex($param->{manifest});
+	$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}} = "importsource=".$disk->{backing_file};
+	}
+	return $result;
+}});
+
 # bash completion helper
 
 sub complete_templet_repo {
diff --git a/www/manager6/Makefile b/www/manager6/Makefile
index 4fa8e1a3..bcd55fad 100644
--- a/www/manager6/Makefile
+++ b/www/manager6/Makefile
@@ -194,8 +194,10 @@ JSSRC= 							\
 	qemu/CmdMenu.js					\
 	qemu/Config.js					\
 	qemu/CreateWizard.js				\
+	qemu/ImportWizard.js				\
 	qemu/DisplayEdit.js				\
 	qemu/HDEdit.js					\
+	qemu/MultiHDEdit.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 9fdae5d1..d9fbfe66 100644
--- a/www/manager6/form/ControllerSelector.js
+++ b/www/manager6/form/ControllerSelector.js
@@ -68,6 +68,22 @@ clist_loop:
 	deviceid.validate();
     },
 
+    getValues: function() {
+	return this.query('field').map(x => x.getValue());
+    },
+
+    getValuesAsString: function() {
+	return this.getValues().join('');
+    },
+
+    setValue: function(value) {
+	console.assert(value);
+	let regex = /([a-z]+)(\d+)/;
+	[_, controller, deviceid] =  regex.exec(value);
+	this.query('field[name=controller]').pop().setValue(controller);
+	this.query('field[name=deviceid]').pop().setValue(deviceid);
+    },
+
     initComponent: function() {
 	var me = this;
 
@@ -85,16 +101,6 @@ clist_loop:
 		    noVirtIO: me.noVirtIO,
 		    allowBlank: false,
 		    flex: 2,
-		    listeners: {
-			change: function(t, value) {
-			    if (!value) {
-				return;
-			    }
-			    var field = me.down('field[name=deviceid]');
-			    field.setMaxValue(PVE.Utils.diskControllerMaxIDs[value]);
-			    field.validate();
-			}
-		    }
 		},
 		{
 		    xtype: 'proxmoxintegerfield',
diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
index 5e0a3981..f8e811e1 100644
--- a/www/manager6/qemu/HDEdit.js
+++ b/www/manager6/qemu/HDEdit.js
@@ -8,6 +8,10 @@ Ext.define('PVE.qemu.HDInputPanel', {
 
     unused: false, // ADD usused disk imaged
 
+    showSourcePathTextfield: false, // to import a disk from an aritrary path
+
+    returnSingleKey: true, // {vmid}/importdisk expects multiple keys => false
+
     vmconfig: {}, // used to select usused disks
 
     viewModel: {},
@@ -58,6 +62,38 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	}
     },
 
+    /*
+    All radiofields (esp. sourceRadioPath and sourceRadioStorage) have the
+    same scope for name. But we need a different scope for each HDInputPanel in
+    a MultiHDInputPanel to get the selectionf or each HDInputPanel => Make
+    names so that those in one HDInputPanel are equal but different from other
+    HDInputPanels
+    */
+    getSourceTypeIdentifier() {
+	console.assert(this.id);
+	return 'sourceType_' + this.id;
+    },
+
+    // values ... the values from onGetValues
+    getSourceValue: function(values) {
+	console.assert(values);
+	let result;
+	let type = values[this.getSourceTypeIdentifier()];
+	console.assert(type === 'storage' || type === 'path',
+	    `type must be 'storage' or 'path' but is ${type}`);
+	if (type === 'storage') {
+	    console.assert(values.sourceVolid,
+		"sourceVolid must be set when type is storage");
+	    result = values.sourceVolid;
+	} else {
+	    console.assert(values.sourcePath,
+		"sourcePath must be set when type is path");
+	    result = values.sourcePath;
+	}
+	console.assert(result);
+	return result;
+    },
+
     onGetValues: function(values) {
 	var me = this;
 
@@ -67,16 +103,18 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	if (me.unused) {
 	    me.drive.file = me.vmconfig[values.unusedId];
 	    confid = values.controller + values.deviceid;
-	} else if (me.isCreate && !me.isImport) {
+	} else if (me.isCreate) {
 	    // disk format & size should not be part of propertyString for import
 	    if (values.hdimage) {
 		me.drive.file = values.hdimage;
+	    } else if (me.isImport) {
+		me.drive.file = `${values.hdstorage}:0`; // so that API allows it
 	    } else {
 		me.drive.file = values.hdstorage + ":" + values.disksize;
 	    }
 	    me.drive.format = values.diskformat;
 	}
-
+	
 	PVE.Utils.propertyStringSet(me.drive, !values.backup, 'backup', '0');
 	PVE.Utils.propertyStringSet(me.drive, values.noreplicate, 'replicate', 'no');
 	PVE.Utils.propertyStringSet(me.drive, values.discard, 'discard', 'on');
@@ -90,15 +128,22 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    PVE.Utils.propertyStringSet(me.drive, values[name], name);
 	    PVE.Utils.propertyStringSet(me.drive, values[burst_name], burst_name);
 	});
-	if (me.isImport) {
+
+	if (me.returnSingleKey) {
+	    if (me.isImport) {
+		me.drive.importsource = this.getSourceValue(values);
+	    }
+	    params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	} else {
+	    console.assert(me.isImport,
+		"Returning multiple key/values is only allowed in import");
 	    params.device_options = PVE.Parser.printPropertyString(me.drive);
-	    params.source = values.sourceType === 'storage'
-		? values.sourceVolid : values.sourcePath;
+	    params.source = this.getSourceValue(values);
 	    params.device = values.controller + values.deviceid;
 	    params.storage = values.hdstorage;
-	    if (values.diskformat) params.format = values.diskformat;
-	} else {
-	    params[confid] = PVE.Parser.printQemuDrive(me.drive);
+	    if (values.diskformat) {
+		params.format = values.diskformat;
+	    }
 	}
 	return params;
     },
@@ -156,10 +201,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	me.setValues(values);
     },
 
+    getDevice: function() {
+	    return this.bussel.getValuesAsString();
+    },
+
     setNodename: function(nodename) {
 	var me = this;
 	me.down('#hdstorage').setNodename(nodename);
 	me.down('#hdimage').setStorage(undefined, nodename);
+	// me.down('#sourceStorageSelector').setNodename(nodename);
+	// me.down('#sourceFileSelector').setNodename(nodename);
     },
 
     initComponent : function() {
@@ -175,12 +226,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	me.advancedColumn1 = [];
 	me.advancedColumn2 = [];
 
+	let nodename = this.getViewModel().get('nodename'); // TODO hacky whacky
+
+
 	if (!me.confid || me.unused) {
-	    let controllerColumn = me.isImport ? me.column2 : me.column1;
+	    let controllerColumn = me.showSourcePathTextfield ? me.column2 : me.column1;
 	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
+		itemId: 'bussel',
 		vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {}
 	    });
-	    if (me.isImport) {
+	    if (me.showSourcePathTextfield) {
 		me.bussel.fieldLabel = 'Target Device';
 	    }
 	    controllerColumn.push(me.bussel);
@@ -210,16 +265,16 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		allowBlank: false
 	    });
 	    me.column1.push(me.unusedDisks);
-	} else if (me.isCreate || me.isImport) {
+	} else if (me.isCreate || me.showSourcePathTextfield) {
 	    let selector = {
 		xtype: 'pveDiskStorageSelector',
 		storageContent: 'images',
 		name: 'disk',
-		nodename: me.nodename,
-		hideSize: me.isImport,
-		autoSelect: me.insideWizard || me.isImport,
+		nodename: nodename,
+		hideSize: me.showSourcePathTextfield,
+		autoSelect: me.insideWizard || me.showSourcePathTextfield,
 	    };
-	    if (me.isImport) {
+	    if (me.showSourcePathTextfield) {
 		selector.storageLabel = gettext('Target storage');
 		me.column2.push(selector);
 	    } else {
@@ -235,7 +290,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
 	    });
 	}
 
-	if (me.isImport) {
+	if (me.showSourcePathTextfield) {
 	    me.column2.push({
 		xtype: 'box',
 		autoEl: { tag: 'hr' },
@@ -255,72 +310,83 @@ Ext.define('PVE.qemu.HDInputPanel', {
 		name: 'discard'
 	    }
 	);
-	if (me.isImport) {
+	if (me.showSourcePathTextfield) {
 	    let show = (element, value) => {
 		element.setHidden(!value);
 		element.setDisabled(!value);
 	    };
-	    me.sourceRadioStorage = Ext.create('Ext.form.field.Radio', {
-		name: 'sourceType',
-		inputValue: 'storage',
-		boxLabel: gettext('Use a storage as source'),
-		checked: true,
-		hidden: Proxmox.UserName !== 'root@pam',
-		listeners: {
-		    added: () => show(me.sourcePathTextfield, false),
-		    change: (_, storageRadioChecked) => {
-			show(me.sourcePathTextfield, !storageRadioChecked);
-			let selectors = [
-			    me.sourceStorageSelector,
-			    me.sourceFileSelector,
-			];
-			for (const selector of selectors) {
-			    show(selector, storageRadioChecked);
-			}
+
+	    me.column1.unshift(
+		{
+		    xtype: 'radiofield',
+		    itemId: 'sourceRadioStorage',
+		    name: me.getSourceTypeIdentifier(),
+		    inputValue: 'storage',
+		    boxLabel: gettext('Use a storage as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    checked: true,
+		    listeners: {
+			change: (_, newValue) => {
+			    let storageSelectors = [
+				me.down('#sourceStorageSelector'),
+				me.down('#sourceFileSelector'),
+			    ];
+			    for (const selector of storageSelectors) {
+				show(selector, newValue);
+			    }
+			},
 		    },
-		},
-	    });
-	    me.sourceStorageSelector = Ext.create('PVE.form.StorageSelector', {
-		name: 'inputImageStorage',
-		nodename: me.nodename,
-		fieldLabel: gettext('Source Storage'),
-		storageContent: 'images',
-		autoSelect: me.insideWizard,
-		listeners: {
-		    change: function(_, selectedStorage) {
-			me.sourceFileSelector.setStorage(selectedStorage);
+		}, {
+		    xtype: 'pveStorageSelector',
+		    itemId: 'sourceStorageSelector',
+		    name: 'inputImageStorage',
+		    nodename: nodename,
+		    fieldLabel: gettext('Source Storage'),
+		    storageContent: 'images',
+		    autoSelect: me.insideWizard,
+		    hidden: true,
+		    disabled: true,
+		    listeners: {
+			change: function (_, selectedStorage) {
+			    me.down('#sourceFileSelector').setStorage(selectedStorage);
+			},
 		    },
+		}, {
+		    xtype: 'pveFileSelector',
+		    itemId: 'sourceFileSelector',
+		    name: 'sourceVolid', // TODO scope of itemId is container, this breaks onGetValues, only one thingy is selected for multiple inputpanels
+		    nodename: nodename,
+		    storageContent: 'images',
+		    hidden: true,
+		    disabled: true,
+		    fieldLabel: gettext('Source Image'),
+		}, {
+		    xtype: 'radiofield',
+		    itemId: 'sourceRadioPath',
+		    name: me.getSourceTypeIdentifier(),
+		    inputValue: 'path',
+		    boxLabel: gettext('Use an absolute path as source'),
+		    hidden: Proxmox.UserName !== 'root@pam',
+		    listeners: {
+			change: (_, newValue) => {
+			    show(me.down('#sourcePathTextfield'), newValue);
+			},
+		    },
+		}, {
+		    xtype: 'textfield',
+		    itemId: 'sourcePathTextfield',
+		    fieldLabel: gettext('Source 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: (insertedText) =>
+			insertedText.startsWith('/') ||
+			    gettext('Must be an absolute path'),
 		},
-	    });
-	    me.sourceFileSelector = Ext.create('PVE.form.FileSelector', {
-		name: 'sourceVolid',
-		nodename: me.nodename,
-		storageContent: 'images',
-		fieldLabel: gettext('Source Image'),
-	    });
-	    me.sourceRadioPath = Ext.create('Ext.form.field.Radio', {
-		name: 'sourceType',
-		inputValue: 'path',
-		boxLabel: gettext('Use an absolute path as source'),
-		hidden: Proxmox.UserName !== 'root@pam',
-	    });
-	    me.sourcePathTextfield = Ext.create('Ext.form.field.Text', {
-		xtype: 'textfield',
-		fieldLabel: gettext('Source Path'),
-		name: 'sourcePath',
-		emptyText: '/home/user/disk.qcow2',
-		hidden: Proxmox.UserName !== 'root@pam',
-		validator: function(insertedText) {
-		    return insertedText.startsWith('/') ||
-			gettext('Must be an absolute path');
-		},
-	    });
-	    me.column1.unshift(
-		me.sourceRadioStorage,
-		me.sourceStorageSelector,
-		me.sourceFileSelector,
-		me.sourceRadioPath,
-		me.sourcePathTextfield,
 	    );
 	}
 
@@ -465,7 +531,8 @@ Ext.define('PVE.qemu.HDEdit', {
 	    nodename: nodename,
 	    unused: unused,
 	    isCreate: me.isCreate,
-	    isImport: me.isImport,
+	    showSourcePathTextfield: me.isImport,
+	    returnSingleKey: !me.isImport,
 	});
 
 	var subject;
diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js
new file mode 100644
index 00000000..c6e91a48
--- /dev/null
+++ b/www/manager6/qemu/ImportWizard.js
@@ -0,0 +1,379 @@
+/*jslint confusion: true*/
+Ext.define('PVE.qemu.ImportWizard', {
+    extend: 'PVE.window.Wizard',
+    alias: 'widget.pveQemuImportWizard',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    viewModel: {
+	data: {
+	    nodename: '',
+	    current: {
+		scsihw: '' // TODO there is some error with apply after render_scsihw??
+	    }
+	}
+    },
+
+    cbindData: {
+	nodename: undefined
+    },
+
+    subject: gettext('Import Virtual Machine'),
+
+    isImport: true,
+
+    addDiskFunction: function () {
+	let me = this;
+	let wizard;
+	if (me.xtype === 'button') {
+		wizard = me.up('window');
+	} else if (me.xtype === 'pveQemuImportWizard') {
+		wizard = me;
+	}
+	console.assert(wizard.xtype === 'pveQemuImportWizard');
+	let multihd = wizard.down('pveQemuMultiHDInputPanel');
+	multihd.addDiskFunction();
+    },
+
+    items: [
+	{
+		xtype: 'inputpanel',
+		title: gettext('Import'),
+		column1: [
+			{
+				xtype: 'pveNodeSelector',
+				name: 'nodename',
+				cbind: {
+				    selectCurNode: '{!nodename}',
+				    preferredValue: '{nodename}'
+				},
+				bind: {
+				    value: '{nodename}'
+				},
+				fieldLabel: gettext('Node'),
+				allowBlank: false,
+				onlineValidator: true
+			    }, {
+				xtype: 'pveGuestIDSelector',
+				name: 'vmid',
+				guestType: 'qemu',
+				value: '',
+				loadNextFreeID: true,
+				validateExists: false
+			    },
+		],
+		column2: [
+			// { // TODO implement the rest
+			// 	xtype: 'filebutton',
+			// 	text: gettext('Load local manifest ...'),
+			// 	allowBlank: true,
+			// 	hidden: Proxmox.UserName !== 'root@pam',
+			// 	disabled: Proxmox.UserName !== 'root@pam',
+			// 	listeners: {
+			// 		change: (button,event,) => {
+			// 			var reader = new FileReader();
+			// 			let wizard = button.up('window');
+			// 			reader.onload = (e) => {
+			// 				let uploaded_ovf = e.target.result;
+			// 				// TODO set fields here
+			// 				// TODO When to upload disks to server?
+			// 			};
+			// 			reader.readAsText(event.target.files[0]);
+			// 			button.disable(); // TODO implement complete reload
+			// 			wizard.down('#successTextfield').show();
+			// 		}
+			// 	}
+			// },
+			{
+				xtype: 'label',
+				itemId: 'successTextfield',
+				hidden: true,
+				html: gettext('Manifest successfully uploaded'),
+				margin: '0 0 0 10',
+			},
+			{
+				xtype: 'textfield',
+				itemId: 'server_ovf_manifest',
+				name: 'ovf_textfield',
+				value: '/mnt/pve/cifs/importing/ovf_from_hyperv/pve/pve.ovf',
+				emptyText: '/mnt/nfs/exported.ovf',
+				fieldLabel: 'Absolute path to .ovf manifest on your PVE host',
+			},
+			{
+				xtype: 'proxmoxButton',
+				text: gettext('Load remote manifest'),
+				handler: function() {
+					let panel = this.up('panel'); 
+					let nodename = panel.down('pveNodeSelector').getValue();
+					 // independent of onGetValues(), so that value of
+					 // ovf_textfield can be removed for submit
+					let ovf_textfield_value = panel.down('#server_ovf_manifest').getValue();
+					let wizard = this.up('window');
+					Proxmox.Utils.API2Request({
+						url: '/nodes/' + nodename + '/readovf',
+						method: 'GET',
+						params: {
+							manifest: ovf_textfield_value,
+						},
+						success: function(response){
+						    let ovfdata = response.result.data;
+						    wizard.down('#vmNameTextfield').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;
+						    let devices = Object.keys(ovfdata); // e.g. ide0, sata2
+						    let multihd = wizard.down('pveQemuMultiHDInputPanel');
+						    if (devices.length > 0) {
+							multihd.removeAllDisks();
+						    }
+						    for (var device of devices) {
+							let path = ovfdata[device].split('=')[1];
+							multihd.addDiskFunction(device, path);
+						    }
+						},
+						failure: function(response, opts) {
+						    console.warn("Failure of load manifest button");
+						    console.warn(response);
+						},
+					    });
+
+				},
+			},
+		],
+		onGetValues: function(values) {
+			delete values.server_ovf_manifest;
+			delete values.ovf_textfield;
+			return values;
+		}
+	},
+	{
+	    xtype: 'inputpanel',
+	    title: gettext('General'),
+	    onlineHelp: 'qm_general_settings',
+	    column1: [
+		{
+		    xtype: 'textfield',
+		    name: 'name',
+		    itemId: 'vmNameTextfield',
+		    vtype: 'DnsName',
+		    value: '',
+		    fieldLabel: gettext('Name'),
+		    allowBlank: true,
+		}
+	    ],
+	    column2: [
+		{
+		    xtype: 'pvePoolSelector',
+		    fieldLabel: gettext('Resource Pool'),
+		    name: 'pool',
+		    value: '',
+		    allowBlank: true
+		}
+	    ],
+	    advancedColumn1: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'onboot',
+		    uncheckedValue: 0,
+		    defaultValue: 0,
+		    deleteDefaultValue: true,
+		    fieldLabel: gettext('Start at boot')
+		}
+	    ],
+	    advancedColumn2: [
+		{
+		    xtype: 'textfield',
+		    name: 'order',
+		    defaultValue: '',
+		    emptyText: 'any',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Start/Shutdown order')
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'up',
+		    defaultValue: '',
+		    emptyText: 'default',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Startup delay')
+		},
+		{
+		    xtype: 'textfield',
+		    name: 'down',
+		    defaultValue: '',
+		    emptyText: 'default',
+		    labelWidth: 120,
+		    fieldLabel: gettext('Shutdown timeout')
+		}
+	    ],
+	    onGetValues: function(values) {
+
+		['name', 'pool', 'onboot', 'agent'].forEach(function(field) {
+		    if (!values[field]) {
+			delete values[field];
+		    }
+		});
+
+		var res = PVE.Parser.printStartup({
+		    order: values.order,
+		    up: values.up,
+		    down: values.down
+		});
+
+		if (res) {
+		    values.startup = res;
+		}
+
+		delete values.order;
+		delete values.up;
+		delete values.down;
+		
+		return values;
+	    }
+	},
+	{
+	    xtype: 'pveQemuSystemPanel',
+	    title: gettext('System'),
+	    isCreate: true,
+	    insideWizard: true
+	},
+	{
+		xtype: 'pveQemuMultiHDInputPanel',
+		title: 'Hard Disk',
+	},
+	{
+	    itemId: 'cpupanel',
+	    xtype: 'pveQemuProcessorPanel',
+	    insideWizard: true,
+	    title: gettext('CPU')
+	},
+	{
+	    itemId: 'memorypanel',
+	    xtype: 'pveQemuMemoryPanel',
+	    insideWizard: true,
+	    title: gettext('Memory')
+	},
+	{
+	    xtype: 'pveQemuNetworkInputPanel',
+	    bind: {
+		nodename: '{nodename}'
+	    },
+	    title: gettext('Network'),
+	    insideWizard: true
+	},
+	{
+	    title: gettext('Confirm'),
+	    layout: 'fit',
+	    items: [
+		{
+		    xtype: 'grid',
+		    store: {
+			model: 'KeyValue',
+			sorters: [{
+			    property : 'key',
+			    direction: 'ASC'
+			}]
+		    },
+		    columns: [
+			{header: 'Key', width: 150, dataIndex: 'key'},
+			{header: 'Value', flex: 1, dataIndex: 'value'}
+		    ]
+		}
+	    ],
+	    dockedItems: [
+		{
+		    xtype: 'proxmoxcheckbox',
+		    name: 'start',
+		    dock: 'bottom',
+		    margin: '5 0 0 0',
+		    boxLabel: gettext('Start after created')
+		}
+	    ],
+	    listeners: {
+		show: function(panel) {
+		    var kv = this.up('window').getValues();
+		    var data = [];
+		    Ext.Object.each(kv, function(key, value) {
+			if (key === 'delete') { // ignore
+			    return;
+			}
+			data.push({ key: key, value: value });
+		    });
+
+		    var summarystore = panel.down('grid').getStore();
+		    summarystore.suspendEvents();
+		    summarystore.removeAll();
+		    summarystore.add(data);
+		    summarystore.sort();
+		    summarystore.resumeEvents();
+		    summarystore.fireEvent('refresh');
+
+		}
+	    },
+	    onSubmit: function() {
+		var wizard = this.up('window');
+		var kv = wizard.getValues();
+		delete kv['delete'];
+
+		var nodename = kv.nodename;
+		delete kv.nodename;
+
+		Proxmox.Utils.API2Request({
+		    url: '/nodes/' + nodename + '/qemu',
+		    waitMsgTarget: wizard,
+		    method: 'POST',
+		    params: kv,
+		    success: function(response){
+			wizard.close();
+		    },
+		    failure: function(response, opts) {
+			Ext.Msg.alert(gettext('Error'), response.htmlStatus);
+		    }
+		});
+	    }
+	}
+	],
+    initComponent: function () {
+	var me = this;
+	me.callParent();
+
+	let addDiskButton = {
+		text: gettext('Add disk'),
+		disabled: true,
+		itemId: 'addDisk',
+		minWidth: 60,
+		handler: me.addDiskFunction,
+		isValid: function () {
+			let isValid = true;
+			if (!me.isImport) {
+				isValid = false;
+			}
+			let type = me.down('#wizcontent').getActiveTab().xtype;
+			if (type !== 'pveQemuHDInputPanel') {
+				isValid=false;
+			}
+			return isValid;
+		},
+	};
+
+	let removeDiskButton = {
+	    text: gettext('Remove disk'), // TODO implement
+	    disabled: false,
+	    itemId: 'removeDisk',
+	    minWidth: 60,
+	    handler: function() {
+		console.assert(me.xtype === 'pveQemuImportWizard');
+		let multihd = me.down('pveQemuMultiHDInputPanel');
+		multihd.removeCurrentDisk();
+	    },
+	};
+	me.down('toolbar').insert(4, addDiskButton);
+	me.down('toolbar').insert(5, removeDiskButton);
+    },
+});
+
+
+
+
diff --git a/www/manager6/qemu/MultiHDEdit.js b/www/manager6/qemu/MultiHDEdit.js
new file mode 100644
index 00000000..632199ba
--- /dev/null
+++ b/www/manager6/qemu/MultiHDEdit.js
@@ -0,0 +1,267 @@
+Ext.define('PVE.qemu.MultiHDInputPanel', {
+    extend: 'Proxmox.panel.InputPanel',
+    alias: 'widget.pveQemuMultiHDInputPanel',
+
+    insideWizard: false,
+
+    hiddenDisks: [],
+
+    leftColumnRatio: 0.2,
+
+    column1: [
+	{
+	    // Adding to the HDInputPanelContainer below automatically adds
+	    // items to this store
+	    xtype: 'gridpanel',
+	    scrollable: true,
+	    store: {
+		xtype: 'store',
+		storeId: 'importwizard_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) {
+		    console.assert(panel.xtype === 'pveQemuHDInputPanel');
+		    let recordIndex = this.findBy(record =>
+			record.data.panel === panel,
+		    );
+		    this.removeAt(recordIndex);
+		    return recordIndex;
+		},
+	    },
+	    columns: [
+		{
+		    text: gettext('Target device'),
+		    dataIndex: 'device',
+		    flex: 1,
+		    resizable: false,
+		},
+	    ],
+	    listeners: {
+		select: function(_, record) {
+		    this.up('pveQemuMultiHDInputPanel')
+			.down('#HDInputPanelContainer')
+			.setActiveItem(record.data.panel);
+		},
+	    },
+	    anchor: '100% 100%', // Required because resize does not happen yet
+	},
+    ],
+    column2: [
+	{
+	    itemId: 'HDInputPanelContainer',
+	    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.addDiskFunction();
+		    }
+		},
+		add: function(container, newPanel, index) {
+		    let store = Ext.getStore('importwizard_diskstorage');
+		    store.add({ device: newPanel.getDevice(), panel: newPanel });
+		    container.setActiveItem(newPanel);
+		},
+		remove: function(HDInputPanelContainer, HDInputPanel, eOpts) {
+		    let store = Ext.getStore('importwizard_diskstorage');
+		    let previousCount = store.data.getCount();
+		    let indexOfRemoved = store.removeByPanel(HDInputPanel);
+		    console.assert(store.data.getCount() === previousCount - 1,
+			'Nothing has been removed from the store.' +
+			`It still has ${store.data.getCount()} items.`,
+		    );
+		    if (HDInputPanelContainer.items.getCount() > 0) {
+			console.assert(indexOfRemoved >= 1);
+			HDInputPanelContainer.setActiveItem(indexOfRemoved - 1);
+		    }
+		},
+	    },
+	    defaultItem: {
+		xtype: 'pveQemuHDInputPanel',
+		bind: {
+		    nodename: '{nodename}',
+		    viewModel: '{viewModel}',
+		},
+		isCreate: true,
+		isImport: true,
+		showSourcePathTextfield: true,
+		returnSingleKey: true,
+		insideWizard: true,
+		listeners: {
+		    // newHDInputPanel ... the defaultItem that has just been
+		    //   cloned and added into HDInputPnaleContainer parameter
+		    // HDInputPanelContainer ... the container from column2
+		    //   where all the new panels go into
+		    added: function(newHDInputPanel, HDInputPanelContainer, pos) {
+			    // The listeners cannot be added earlier, because its fields don't exist earlier
+			    Ext.Array.each(this.down('pveControllerSelector')
+			    .query('field'), function(field) {
+				field.on('change', function() {
+				    // Note that one setValues in a controller
+				    // selector makes one setValue in each of
+				    // the two fields, so this listener fires
+				    // two times in a row so to say e.g.
+				    // changing controller selector from ide0 to
+				    // sata1 makes ide0->sata0 and then
+				    // sata0->sata1
+				    let store = Ext.getStore('importwizard_diskstorage');
+				    let controllerSelector = field.up('pveQemuHDInputPanel')
+					.down('pveControllerSelector');
+				    /*
+				     * controller+device (ide0) might be
+				     * ambiguous during creation => find by
+				     * panel object instead
+				     *
+				     * There is no function that takes a
+				     * function and returns the model directly
+				     * => index & getAt
+				     */
+				    let recordIndex = store.findBy(record =>
+					record.data.panel === field.up('pveQemuHDInputPanel'),
+				    );
+				    console.assert(
+					newHDInputPanel === field.up('pveQemuHDInputPanel'),
+					'Those panels should be the same',
+				    );
+				    console.assert(recordIndex !== -1);
+				    let newControllerAndId = controllerSelector.getValuesAsString();
+				    store.getAt(recordIndex).set('device', newControllerAndId);
+				});
+			    },
+			);
+		    },
+		    beforerender: function() {
+			let wizard = this.up('pveQemuImportWizard');
+			Ext.Array.each(this.query('field'), function(field) {
+			    field.on('change', wizard.validcheck);
+			    field.on('validitychange', wizard.validcheck);
+			});
+		    },
+		},
+		validator: function() {
+		    console.debug('hdedit validator');
+		    var valid = true;
+		    var fields = this.query('field, fieldcontainer');
+		    if (this.isXType('fieldcontainer')) {
+			console.assert(false);
+			fields.unshift(this);
+		    }
+		    Ext.Array.each(fields, function(field) {
+			// Note: not all fielcontainer have isValid()
+			if (Ext.isFunction(field.isValid) && !field.isValid()) {
+			    valid = false;
+			    console.debug('field is invalid');
+			    console.debug(field);
+			}
+		    });
+		    return valid;
+		}
+	    },
+
+	    // device ... device that the new disk should be assigned to, e.g.
+	    //   ide0, sata2
+	    // path ... if this is set to x then the disk will
+	    //   backed/imported from the path x, that is, the textfield will
+	    //   contain the value x
+	    addDiskFunction(device, path) {
+		// creating directly removes binding => no storage found?
+		let item = Ext.clone(this.defaultItem);
+		let added = this.add(item);
+		// At this point the 'added' listener has fired and the fields
+		// in the variable added have the change listeners that update
+		// the store Therefore we can now set values only on the field
+		// and they will be updated in the store
+		if (path) {
+		    added.down('#sourceRadioPath').setValue(true);
+		    added.down('#sourcePathTextfield').setValue(path);
+		} else {
+		    added.down('#sourceRadioStorage').setValue(true);
+		    added.down('#sourceStorageSelector').setHidden(false);
+		    added.down('#sourceFileSelector').setHidden(false);
+		    added.down('#sourceFileSelector').enable();
+		    added.down('#sourceStorageSelector').enable();
+		}
+		if (device) {
+		    // This happens after the 'add' and 'added' listeners of the
+		    // item/defaultItem clone/pveQemuHDInputPanel/added have fired
+		    added.down('pveControllerSelector').setValue(device);
+		}
+	    },
+	    removeCurrentDisk: function() {
+		let activePanel = this.getLayout().activeItem; // panel = disk
+		if (activePanel) {
+		    this.remove(activePanel);
+		} else {
+			// TODO Add tooltip to Remove disk button
+		}
+	    },
+	},
+    ],
+
+    addDiskFunction: function(device, path) {
+	this.down('#HDInputPanelContainer').addDiskFunction(device, path);
+    },
+    removeCurrentDisk: function() {
+	this.down('#HDInputPanelContainer').removeCurrentDisk();
+    },
+    removeAllDisks: function() {
+	let container = this.down('#HDInputPanelContainer');
+	while (container.items.items.length > 0) {
+		container.removeCurrentDisk();
+	}
+    },
+
+    beforeRender: function() {
+	// any other panel because this has no height yet
+	let panelHeight = this.up('tabpanel').items.items[0].getHeight();
+	let leftColumnContainer = this.items.items[0];
+	let rightColumnContainer = this.items.items[1];
+	leftColumnContainer.setHeight(panelHeight);
+
+	leftColumnContainer.columnWidth = this.leftColumnRatio;
+	rightColumnContainer.columnWidth = 1 - this.leftColumnRatio;
+    },
+
+    // Call with defined parameter or without (static function so to say)
+    hasDuplicateDevices: function(values) {
+	if (!values) {
+	    values = this.up('form').getValues();
+	}
+	console.assert(values);
+	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]) {
+			if (values.deviceid[i] === values.deviceid[j]) {
+			    return true;
+			}
+		    }
+		}
+	    }
+	return false;
+    },
+
+    onGetValues: function(values) {
+	// Returning anything here would give wrong data in the form at the end
+	// of the wizrad Each HDInputPanel in this MultiHD panel already has a
+	// sufficient onGetValues() function for the form at the end of the
+	// wizard
+	if (this.hasDuplicateDevices(values)) {
+	    Ext.Msg.alert(gettext('Error'), 'Equal target devices are forbidden. Make all unique!');
+	}
+    },
+
+    validator: function() {
+	let inputpanels = this.down('#HDInputPanelContainer').items.getRange();
+	if (inputpanels.some(panel => !panel.validator())) {
+	    return false;
+	}
+	if (this.hasDuplicateDevices()) {
+	    return false;
+	}
+	return true;
+    },
+});
diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js
index 87e4bf0a..f16ba107 100644
--- a/www/manager6/window/Wizard.js
+++ b/www/manager6/window/Wizard.js
@@ -35,6 +35,75 @@ Ext.define('PVE.window.Wizard', {
         return values;
     },
 
+    check_card: function(card) {
+	var valid = true;
+	var fields = card.query('field, fieldcontainer');
+	if (card.isXType('fieldcontainer')) {
+	    fields.unshift(card);
+	}
+	Ext.Array.each(fields, function(field) {
+	    // Note: not all fielcontainer have isValid()
+	    if (Ext.isFunction(field.isValid) && !field.isValid()) {
+		valid = false;
+	    }
+	});
+
+	if (Ext.isFunction(card.validator)) {
+	    return card.validator();
+	}
+
+	return valid;
+    },
+
+    disable_at: function(card) {
+	let window = this;
+	var topbar = window.down('#wizcontent');
+	var idx = topbar.items.indexOf(card);
+	for(;idx < topbar.items.getCount();idx++) {
+	    var nc = topbar.items.getAt(idx);
+	    if (nc) {
+		nc.disable();
+	    }
+	}
+    },
+
+    validcheck: function() {
+	console.debug('Validcheck');
+	let window = this.up('window');
+	var topbar = window.down('#wizcontent');
+
+	// check tabs from current to the last enabled for validity
+	// since we might have changed a validity on a later one
+	var i;
+	for (i = topbar.curidx; i <= topbar.maxidx && i < topbar.items.getCount(); i++) {
+	    var tab = topbar.items.getAt(i);
+	    var valid = window.check_card(tab);
+
+	    // only set the buttons on the current panel
+	    if (i === topbar.curidx) {
+		if (window.isImport) {
+		    console.debug('valid in window?');
+		    console.debug(valid);
+		    console.debug('because tab is');
+		    console.debug(tab);
+		    window.down('#addDisk').setDisabled(!valid);
+		}
+		window.down('#next').setDisabled(!valid);
+		window.down('#submit').setDisabled(!valid);
+	    }
+
+	    // if a panel is invalid, then disable it and all following,
+	    // else enable it and go to the next
+	    var ntab = topbar.items.getAt(i + 1);
+	    if (!valid) {
+		window.disable_at(ntab);
+		return;
+	    } else if (ntab && !tab.onSubmit) {
+		ntab.enable();
+	    }
+	}
+    },
+
     initComponent: function() {
 	var me = this;
 
@@ -53,40 +122,6 @@ Ext.define('PVE.window.Wizard', {
 	});
 	tabs[0].disabled = false;
 
-	var maxidx = 0;
-	var curidx = 0;
-
-	var check_card = function(card) {
-	    var valid = true;
-	    var fields = card.query('field, fieldcontainer');
-	    if (card.isXType('fieldcontainer')) {
-		fields.unshift(card);
-	    }
-	    Ext.Array.each(fields, function(field) {
-		// Note: not all fielcontainer have isValid()
-		if (Ext.isFunction(field.isValid) && !field.isValid()) {
-		    valid = false;
-		}
-	    });
-
-	    if (Ext.isFunction(card.validator)) {
-		return card.validator();
-	    }
-
-	    return valid;
-	};
-
-	var disable_at = function(card) {
-	    var tp = me.down('#wizcontent');
-	    var idx = tp.items.indexOf(card);
-	    for(;idx < tp.items.getCount();idx++) {
-		var nc = tp.items.getAt(idx);
-		if (nc) {
-		    nc.disable();
-		}
-	    }
-	};
-
 	var tabchange = function(tp, newcard, oldcard) {
 	    if (newcard.onSubmit) {
 		me.down('#next').setVisible(false);
@@ -95,16 +130,23 @@ Ext.define('PVE.window.Wizard', {
 		me.down('#next').setVisible(true);
 		me.down('#submit').setVisible(false); 
 	    }
-	    var valid = check_card(newcard);
+	    var valid = me.check_card(newcard);
+	    let addDiskButton = me.down('#addDisk'); // TODO undefined in first invocation?
+	    if (me.isImport && addDiskButton) {
+		addDiskButton.setDisabled(!valid); // TODO check me
+		addDiskButton.setHidden(!addDiskButton.isValid());
+		addDiskButton.setDisabled(false);
+		addDiskButton.setHidden(false);
+	    }
 	    me.down('#next').setDisabled(!valid);    
 	    me.down('#submit').setDisabled(!valid);    
 	    me.down('#back').setDisabled(tp.items.indexOf(newcard) == 0);
 
 	    var idx = tp.items.indexOf(newcard);
-	    if (idx > maxidx) {
-		maxidx = idx;
+	    if (idx > tp.maxidx) {
+		tp.maxidx = idx;
 	    }
-	    curidx = idx;
+	    tp.curidx = idx;
 
 	    var next = idx + 1;
 	    var ntab = tp.items.getAt(next);
@@ -135,6 +177,8 @@ Ext.define('PVE.window.Wizard', {
 		    items: [{
 			itemId: 'wizcontent',
 			xtype: 'tabpanel',
+			maxidx: 0,
+			curidx: 0,
 			activeItem: 0,
 			bodyPadding: 10,
 			listeners: {
@@ -201,7 +245,7 @@ Ext.define('PVE.window.Wizard', {
 
 			var tp = me.down('#wizcontent');
 			var atab = tp.getActiveTab();
-			if (!check_card(atab)) {
+			if (!me.check_card(atab)) {
 			    return;
 			}
 
@@ -234,35 +278,8 @@ Ext.define('PVE.window.Wizard', {
 	});
 
 	Ext.Array.each(me.query('field'), function(field) {
-	    var validcheck = function() {
-		var tp = me.down('#wizcontent');
-
-		// check tabs from current to the last enabled for validity
-		// since we might have changed a validity on a later one
-		var i;
-		for (i = curidx; i <= maxidx && i < tp.items.getCount(); i++) {
-		    var tab = tp.items.getAt(i);
-		    var valid = check_card(tab);
-
-		    // only set the buttons on the current panel
-		    if (i === curidx) {
-			me.down('#next').setDisabled(!valid);
-			me.down('#submit').setDisabled(!valid);
-		    }
-
-		    // if a panel is invalid, then disable it and all following,
-		    // else enable it and go to the next
-		    var ntab = tp.items.getAt(i + 1);
-		    if (!valid) {
-			disable_at(ntab);
-			return;
-		    } else if (ntab && !tab.onSubmit) {
-			ntab.enable();
-		    }
-		}
-	    };
-	    field.on('change', validcheck);
-	    field.on('validitychange', validcheck);
+	    field.on('change', me.validcheck);
+	    field.on('validitychange', me.validcheck);
 	});
     }
 });
-- 
2.20.1




^ permalink raw reply	[flat|nested] 6+ messages in thread

* Re: [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk
  2020-11-19  9:46 ` [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk Dominic Jäger
@ 2020-11-20  8:15   ` Dominic Jäger
  0 siblings, 0 replies; 6+ messages in thread
From: Dominic Jäger @ 2020-11-20  8:15 UTC (permalink / raw)
  To: pve-devel

This is actually broken, sorry. I'm just fixing it. But clicking through the wizard should work.

On Thu, Nov 19, 2020 at 10:46:18AM +0100, Dominic Jäger wrote:
> Make importing single disks easier.
> Required to import a whole VM via GUI.
> 
> Signed-off-by: Dominic Jäger <d.jaeger@proxmox.com>
> ---
>  www/manager6/qemu/HDEdit.js       | 134 ++++++++++++++++++++++++++----
>  www/manager6/qemu/HardwareView.js |  24 ++++++
>  2 files changed, 141 insertions(+), 17 deletions(-)
> 
> diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js
> index e2a5b914..5e0a3981 100644
> --- a/www/manager6/qemu/HDEdit.js
> +++ b/www/manager6/qemu/HDEdit.js
> @@ -67,7 +67,8 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	if (me.unused) {
>  	    me.drive.file = me.vmconfig[values.unusedId];
>  	    confid = values.controller + values.deviceid;
> -	} else if (me.isCreate) {
> +	} else if (me.isCreate && !me.isImport) {
> +	    // disk format & size should not be part of propertyString for import
>  	    if (values.hdimage) {
>  		me.drive.file = values.hdimage;
>  	    } else {
> @@ -83,16 +84,22 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	PVE.Utils.propertyStringSet(me.drive, values.iothread, 'iothread', 'on');
>  	PVE.Utils.propertyStringSet(me.drive, values.cache, 'cache');
>  
> -        var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
> -        Ext.Array.each(names, function(name) {
> -            var burst_name = name + '_max';
> +	var names = ['mbps_rd', 'mbps_wr', 'iops_rd', 'iops_wr'];
> +	Ext.Array.each(names, function(name) {
> +	    var 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);
> -
> +	});
> +	if (me.isImport) {
> +	    params.device_options = PVE.Parser.printPropertyString(me.drive);
> +	    params.source = values.sourceType === 'storage'
> +		? values.sourceVolid : values.sourcePath;
> +	    params.device = values.controller + values.deviceid;
> +	    params.storage = values.hdstorage;
> +	    if (values.diskformat) params.format = values.diskformat;
> +	} else {
> +	    params[confid] = PVE.Parser.printQemuDrive(me.drive);
> +	}
>  	return params;
>      },
>  
> @@ -169,10 +176,14 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	me.advancedColumn2 = [];
>  
>  	if (!me.confid || me.unused) {
> +	    let controllerColumn = me.isImport ? me.column2 : me.column1;
>  	    me.bussel = Ext.create('PVE.form.ControllerSelector', {
>  		vmconfig: me.insideWizard ? {ide2: 'cdrom'} : {}
>  	    });
> -	    me.column1.push(me.bussel);
> +	    if (me.isImport) {
> +		me.bussel.fieldLabel = 'Target Device';
> +	    }
> +	    controllerColumn.push(me.bussel);
>  
>  	    me.scsiController = Ext.create('Ext.form.field.Display', {
>  		fieldLabel: gettext('SCSI Controller'),
> @@ -184,7 +195,7 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  		submitValue: false,
>  		hidden: true
>  	    });
> -	    me.column1.push(me.scsiController);
> +	    controllerColumn.push(me.scsiController);
>  	}
>  
>  	if (me.unused) {
> @@ -199,14 +210,21 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  		allowBlank: false
>  	    });
>  	    me.column1.push(me.unusedDisks);
> -	} else if (me.isCreate) {
> -	    me.column1.push({
> +	} else if (me.isCreate || me.isImport) {
> +	    let selector = {
>  		xtype: 'pveDiskStorageSelector',
>  		storageContent: 'images',
>  		name: 'disk',
>  		nodename: me.nodename,
> -		autoSelect: me.insideWizard
> -	    });
> +		hideSize: me.isImport,
> +		autoSelect: me.insideWizard || me.isImport,
> +	    };
> +	    if (me.isImport) {
> +		selector.storageLabel = gettext('Target storage');
> +		me.column2.push(selector);
> +	    } else {
> +		me.column1.push(selector);
> +	    }
>  	} else {
>  	    me.column1.push({
>  		xtype: 'textfield',
> @@ -217,6 +235,12 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  	    });
>  	}
>  
> +	if (me.isImport) {
> +	    me.column2.push({
> +		xtype: 'box',
> +		autoEl: { tag: 'hr' },
> +	    });
> +	}
>  	me.column2.push(
>  	    {
>  		xtype: 'CacheTypeSelector',
> @@ -231,6 +255,74 @@ Ext.define('PVE.qemu.HDInputPanel', {
>  		name: 'discard'
>  	    }
>  	);
> +	if (me.isImport) {
> +	    let show = (element, value) => {
> +		element.setHidden(!value);
> +		element.setDisabled(!value);
> +	    };
> +	    me.sourceRadioStorage = Ext.create('Ext.form.field.Radio', {
> +		name: 'sourceType',
> +		inputValue: 'storage',
> +		boxLabel: gettext('Use a storage as source'),
> +		checked: true,
> +		hidden: Proxmox.UserName !== 'root@pam',
> +		listeners: {
> +		    added: () => show(me.sourcePathTextfield, false),
> +		    change: (_, storageRadioChecked) => {
> +			show(me.sourcePathTextfield, !storageRadioChecked);
> +			let selectors = [
> +			    me.sourceStorageSelector,
> +			    me.sourceFileSelector,
> +			];
> +			for (const selector of selectors) {
> +			    show(selector, storageRadioChecked);
> +			}
> +		    },
> +		},
> +	    });
> +	    me.sourceStorageSelector = Ext.create('PVE.form.StorageSelector', {
> +		name: 'inputImageStorage',
> +		nodename: me.nodename,
> +		fieldLabel: gettext('Source Storage'),
> +		storageContent: 'images',
> +		autoSelect: me.insideWizard,
> +		listeners: {
> +		    change: function(_, selectedStorage) {
> +			me.sourceFileSelector.setStorage(selectedStorage);
> +		    },
> +		},
> +	    });
> +	    me.sourceFileSelector = Ext.create('PVE.form.FileSelector', {
> +		name: 'sourceVolid',
> +		nodename: me.nodename,
> +		storageContent: 'images',
> +		fieldLabel: gettext('Source Image'),
> +	    });
> +	    me.sourceRadioPath = Ext.create('Ext.form.field.Radio', {
> +		name: 'sourceType',
> +		inputValue: 'path',
> +		boxLabel: gettext('Use an absolute path as source'),
> +		hidden: Proxmox.UserName !== 'root@pam',
> +	    });
> +	    me.sourcePathTextfield = Ext.create('Ext.form.field.Text', {
> +		xtype: 'textfield',
> +		fieldLabel: gettext('Source Path'),
> +		name: 'sourcePath',
> +		emptyText: '/home/user/disk.qcow2',
> +		hidden: Proxmox.UserName !== 'root@pam',
> +		validator: function(insertedText) {
> +		    return insertedText.startsWith('/') ||
> +			gettext('Must be an absolute path');
> +		},
> +	    });
> +	    me.column1.unshift(
> +		me.sourceRadioStorage,
> +		me.sourceStorageSelector,
> +		me.sourceFileSelector,
> +		me.sourceRadioPath,
> +		me.sourcePathTextfield,
> +	    );
> +	}
>  
>  	me.advancedColumn1.push(
>  	    {
> @@ -372,14 +464,19 @@ Ext.define('PVE.qemu.HDEdit', {
>  	    confid: me.confid,
>  	    nodename: nodename,
>  	    unused: unused,
> -	    isCreate: me.isCreate
> +	    isCreate: me.isCreate,
> +	    isImport: me.isImport,
>  	});
>  
>  	var subject;
>  	if (unused) {
>  	    me.subject = gettext('Unused Disk');
> +	} else if (me.isImport) {
> +	    me.subject = gettext('Import Disk');
> +	    me.submitText = 'Import';
> +	    me.backgroundDelay = undefined;
>  	} else if (me.isCreate) {
> -            me.subject = gettext('Hard Disk');
> +	    me.subject = gettext('Hard Disk');
>  	} else {
>             me.subject = gettext('Hard Disk') + ' (' + me.confid + ')';
>  	}
> @@ -404,6 +501,9 @@ Ext.define('PVE.qemu.HDEdit', {
>  		    ipanel.setDrive(drive);
>  		    me.isValid(); // trigger validation
>  		}
> +		if (me.isImport) {
> +		    me.url = me.url.replace(/\/config$/, "/importdisk");
> +		}
>  	    }
>  	});
>      }
> diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js
> index 40b3fe86..dc5e217e 100644
> --- a/www/manager6/qemu/HardwareView.js
> +++ b/www/manager6/qemu/HardwareView.js
> @@ -436,6 +436,29 @@ Ext.define('PVE.qemu.HardwareView', {
>  	    handler: run_move
>  	});
>  
> +	var import_btn = new Proxmox.button.Button({
> +	    text: gettext('Import disk'),
> +	    hidden: !(
> +		caps.storage['Datastore.Audit'] &&
> +		caps.storage['Datastore.Allocate'] &&
> +		caps.storage['Datastore.AllocateTemplate'] &&
> +		caps.storage['Datastore.AllocateSpace'] &&
> +		caps.vms['VM.Allocate'] &&
> +		caps.vms['VM.Config.Disk'] &&
> +		true
> +	    ),
> +	    handler: function() {
> +		var win = Ext.create('PVE.qemu.HDEdit', {
> +		    method: 'POST',
> +		    url: `/api2/extjs/${baseurl}`,
> +		    pveSelNode: me.pveSelNode,
> +		    isImport: true,
> +		});
> +		win.on('destroy', me.reload, me);
> +		win.show();
> +	    },
> +	});
> +
>  	var remove_btn = new Proxmox.button.Button({
>  	    text: gettext('Remove'),
>  	    defaultText: gettext('Remove'),
> @@ -752,6 +775,7 @@ Ext.define('PVE.qemu.HardwareView', {
>  		edit_btn,
>  		resize_btn,
>  		move_btn,
> +		import_btn,
>  		revert_btn
>  	    ],
>  	    rows: rows,
> -- 
> 2.20.1
> 
> 
> _______________________________________________
> pve-devel mailing list
> pve-devel@lists.proxmox.com
> https://lists.proxmox.com/cgi-bin/mailman/listinfo/pve-devel




^ permalink raw reply	[flat|nested] 6+ messages in thread

end of thread, other threads:[~2020-11-20  8:16 UTC | newest]

Thread overview: 6+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2020-11-19  9:46 [pve-devel] [PATCH 0/4] Importwizard Dominic Jäger
2020-11-19  9:46 ` [pve-devel] [PATCH qemu-server 1/4] Move importdisk from qm to API Dominic Jäger
2020-11-19  9:46 ` [pve-devel] [PATCH manager 2/4] gui: Hardware View: Add GUI for importdisk Dominic Jäger
2020-11-20  8:15   ` Dominic Jäger
2020-11-19  9:46 ` [pve-devel] [PATCH manager 3/4] gui: Add button & cmdmenu Dominic Jäger
2020-11-19  9:46 ` [pve-devel] [PATCH manager 4/4] gui: Add importdisk wizard 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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal