* [pve-devel] [PATCH] Add API for VM import @ 2021-04-12 10:08 Dominic Jäger 2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger 2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht 0 siblings, 2 replies; 4+ messages in thread From: Dominic Jäger @ 2021-04-12 10:08 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> --- v8: - Fabian moved the import functions into the existing create_vm / update_vm_api - Dropped the separate API endpoints & import lock 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 c56b609..5c31756 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 fdb2ac9..ac2fe2e 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 { @@ -6914,7 +6952,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.20.1 ^ permalink raw reply [flat|nested] 4+ messages in thread
* [pve-devel] [PATCH] Add GUI to import disk & VM 2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger @ 2021-04-12 10:08 ` Dominic Jäger 2021-04-13 10:11 ` Oguz Bektas 2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht 1 sibling, 1 reply; 4+ messages in thread From: Dominic Jäger @ 2021-04-12 10:08 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> --- v8: - Adapt to new API - Some small fixes - Much renaming PVE/API2/Nodes.pm | 7 + www/manager6/Makefile | 2 + www/manager6/Workspace.js | 15 ++ www/manager6/form/ControllerSelector.js | 15 ++ www/manager6/node/CmdMenu.js | 13 + www/manager6/qemu/HDEdit.js | 149 ++++++++++- www/manager6/qemu/HDEditCollection.js | 263 ++++++++++++++++++++ www/manager6/qemu/HardwareView.js | 24 ++ www/manager6/qemu/ImportWizard.js | 317 ++++++++++++++++++++++++ www/manager6/window/Wizard.js | 2 + 10 files changed, 795 insertions(+), 12 deletions(-) create mode 100644 www/manager6/qemu/HDEditCollection.js create mode 100644 www/manager6/qemu/ImportWizard.js diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm index ba6621c6..1cee6cb5 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; @@ -76,6 +77,11 @@ __PACKAGE__->register_method ({ path => 'cpu', }); +__PACKAGE__->register_method ({ + subclass => "PVE::API2::Qemu::OVF", + path => 'readovf', +}); + __PACKAGE__->register_method ({ subclass => "PVE::API2::LXC", path => 'lxc', @@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({ return undef; }}); + # bash completion helper sub complete_templet_repo { diff --git a/www/manager6/Makefile b/www/manager6/Makefile index a2f7be6d..dbb85062 100644 --- a/www/manager6/Makefile +++ b/www/manager6/Makefile @@ -196,8 +196,10 @@ JSSRC= \ qemu/CmdMenu.js \ qemu/Config.js \ qemu/CreateWizard.js \ + qemu/ImportWizard.js \ qemu/DisplayEdit.js \ qemu/HDEdit.js \ + qemu/HDEditCollection.js \ qemu/HDEfi.js \ qemu/HDMove.js \ qemu/HDResize.js \ diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js index 0c1b9e0c..631739a0 100644 --- a/www/manager6/Workspace.js +++ b/www/manager6/Workspace.js @@ -280,11 +280,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"), + hidden: Proxmox.UserName !== 'root@pam', + 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']); } }); @@ -332,6 +346,7 @@ Ext.define('PVE.StdWorkspace', { }, createVM, createCT, + importVM, { pack: 'end', margin: '0 5 0 0', diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js index 23c61159..f515b220 100644 --- a/www/manager6/form/ControllerSelector.js +++ b/www/manager6/form/ControllerSelector.js @@ -68,6 +68,21 @@ clist_loop: 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/node/CmdMenu.js b/www/manager6/node/CmdMenu.js index b650bfa0..b66c7a6e 100644 --- a/www/manager6/node/CmdMenu.js +++ b/www/manager6/node/CmdMenu.js @@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', { wiz.show(); }, }, + { + text: gettext("Import VM"), + hidden: Proxmox.UserName !== 'root@pam', + itemId: 'importvm', + iconCls: 'fa fa-cube', + handler: function() { + const me = this.up('menu'); + const wiz = Ext.create('PVE.qemu.ImportWizard', { + nodename: me.nodename, + }); + wiz.show(); + }, + }, { xtype: 'menuseparator' }, { text: gettext('Bulk Start'), diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js index e22111bf..3af7e624 100644 --- a/www/manager6/qemu/HDEdit.js +++ b/www/manager6/qemu/HDEdit.js @@ -58,6 +58,17 @@ 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 HDInputPanelCollection to get the selection for each HDInputPanel => Make + names so that those within one HDInputPanel are equal, but different from other + HDInputPanels + */ + getSourceTypeID() { + return 'sourceType_' + this.id; + }, + onGetValues: function(values) { var me = this; @@ -70,6 +81,8 @@ Ext.define('PVE.qemu.HDInputPanel', { } else if (me.isCreate) { if (values.hdimage) { me.drive.file = values.hdimage; + } else if (me.isImport) { + me.drive.file = `${values.hdstorage}:-1`; } else { me.drive.file = values.hdstorage + ":" + values.disksize; } @@ -83,13 +96,21 @@ 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); - }); + }); + const getSourceImageLocation = function() { + const type = values[me.getSourceTypeID()]; + return type === 'storage' ? values.sourceVolid : values.sourcePath; + }; + + if (me.isImport) { + params.import_sources = `${confid}=${getSourceImageLocation()}`; + } params[confid] = PVE.Parser.printQemuDrive(me.drive); @@ -149,6 +170,10 @@ 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); @@ -169,10 +194,15 @@ 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', { + itemId: 'bussel', 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 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', { submitValue: false, hidden: true, }); - me.column1.push(me.scsiController); + controllerColumn.push(me.scsiController); } if (me.unused) { @@ -199,14 +229,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 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', { }); } + if (me.isImport) { + me.column2.push({ + xtype: 'box', + autoEl: { tag: 'hr' }, + }); + } me.column2.push( { xtype: 'CacheTypeSelector', @@ -231,6 +274,84 @@ Ext.define('PVE.qemu.HDInputPanel', { name: 'discard', }, ); + if (me.isImport) { + me.column1.unshift( + { + xtype: 'radiofield', + itemId: 'sourceRadioStorage', + name: me.getSourceTypeID(), + inputValue: 'storage', + boxLabel: gettext('Use a storage as source'), + hidden: Proxmox.UserName !== 'root@pam', + checked: true, + listeners: { + change: (_, newValue) => { + me.down('#sourceStorageSelector').setHidden(!newValue); + me.down('#sourceStorageSelector').setDisabled(!newValue); + me.down('#sourceFileSelector').setHidden(!newValue); + me.down('#sourceFileSelector').setDisabled(!newValue); + }, + }, + }, { + xtype: 'pveStorageSelector', + itemId: 'sourceStorageSelector', + name: 'inputImageStorage', + nodename: me.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', + nodename: me.nodename, + storageContent: 'images', + hidden: true, + disabled: true, + fieldLabel: gettext('Source 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"), + }, + }, { + xtype: 'radiofield', + itemId: 'sourceRadioPath', + name: me.getSourceTypeID(), + inputValue: 'path', + boxLabel: gettext('Use an absolute path as source'), + hidden: Proxmox.UserName !== 'root@pam', + listeners: { + change: (_, newValue) => { + me.down('#sourcePathTextfield').setHidden(!newValue); + me.down('#sourcePathTextfield').setDisabled(!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: function(insertedText) { + return insertedText.startsWith('/') || + insertedText.startsWith('http') || + gettext('Must be an absolute path or URL'); + }, + }, + ); + } me.advancedColumn1.push( { @@ -373,13 +494,17 @@ Ext.define('PVE.qemu.HDEdit', { nodename: nodename, unused: unused, 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 + ')'; } diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js new file mode 100644 index 00000000..33f6193a --- /dev/null +++ b/www/manager6/qemu/HDEditCollection.js @@ -0,0 +1,263 @@ +Ext.define('PVE.qemu.HDInputPanelCollection', { + extend: 'Proxmox.panel.InputPanel', + alias: 'widget.pveQemuHDInputPanelCollection', + + insideWizard: false, + + hiddenDisks: [], + + leftColumnRatio: 0.25, + + column1: [ + { + // Adding to the panelContainer 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) { + 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('Target device'), + dataIndex: 'device', + flex: 1, + resizable: false, + }, + ], + listeners: { + select: function(_, record) { + this.up('pveQemuHDInputPanelCollection') + .down('#panelContainer') + .setActiveItem(record.data.panel); + }, + }, + anchor: '100% 90%', + selectLast: function() { + this.setSelection(this.store.getLast()); + }, + }, { + xtype: 'container', + layout: 'hbox', + center: true, + defaults: { + margin: '5', + xtype: 'button', + }, + items: [ + { + iconCls: 'fa fa-plus-circle', + itemId: 'addDisk', + handler: function(button) { + button.up('pveQemuHDInputPanelCollection').addDisk(); + }, + }, { + iconCls: 'fa fa-trash-o', + itemId: 'removeDisk', + handler: function(button) { + button.up('pveQemuHDInputPanelCollection').removeCurrentDisk(); + }, + }, + ], + }, + ], + 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('importwizard_diskstorage'); + store.add({ device: newPanel.getDevice(), panel: newPanel }); + container.setActiveItem(newPanel); + }, + remove: function(panelContainer, HDInputPanel, eOpts) { + const store = Ext.getStore('importwizard_diskstorage'); + store.removeByPanel(HDInputPanel); + if (panelContainer.items.getCount() > 0) { + panelContainer.setActiveItem(0); + } + }, + }, + defaultItem: { + xtype: 'pveQemuHDInputPanel', + bind: { + nodename: '{nodename}', + }, + isCreate: true, + isImport: true, + insideWizard: true, + setNodename: function(nodename) { + this.down('#hdstorage').setNodename(nodename); + this.down('#hdimage').setStorage(undefined, nodename); + this.down('#sourceStorageSelector').setNodename(nodename); + this.down('#sourceFileSelector').setNodename(nodename); + }, + listeners: { + // newPanel ... this cloned + added defaultItem + added: function(newPanel) { + Ext.Array.each(newPanel.down('pveControllerSelector').query('field'), + function(field) { + // Add here because the fields don't exist earlier + field.on('change', function() { + const store = Ext.getStore('importwizard_diskstorage'); + + // find by panel object because it is unique + const recordIndex = store.findBy(record => + record.data.panel === field.up('pveQemuHDInputPanel'), + ); + const controllerSelector = field.up('pveControllerSelector'); + const newControllerAndId = controllerSelector.getValuesAsString(); + + store.getAt(recordIndex).set('device', newControllerAndId); + }); + }, + ); + const wizard = this.up('pveQemuImportWizard'); + Ext.Array.each(this.query('field'), function(field) { + field.on('change', wizard.validcheck); + field.on('validitychange', wizard.validcheck); + }); + }, + }, + validator: function() { + var valid = true; + var fields = this.query('field, fieldcontainer'); + Ext.Array.each(fields, function(field) { + // Note: not all fielcontainer have isValid() + 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 sourcePathTextfield + addDisk(device, path) { + const item = Ext.clone(this.defaultItem); + const added = this.add(item); + // values in the storage will be updated by listeners + 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(); + } + + const sp = Ext.state.Manager.getProvider(); + const advanced_checkbox = sp.get('proxmox-advanced-cb'); + added.setAdvancedVisible(advanced_checkbox); + + if (device) { + added.down('pveControllerSelector').setValue(device); + } + 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); + leftColumnPanel.setFlex(me.leftColumnRatio); + // any other panel because this has no height yet + const panelHeight = me.up('tabpanel').items.get(0).getHeight(); + leftColumnPanel.setHeight(panelHeight); + }, + + setNodename: function(nodename) { + this.nodename = nodename; + }, + + listeners: { + afterrender: function() { + const store = Ext.getStore('importwizard_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 HDInputPanel 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(); + }, +}); diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js index 98352e3f..be4e2d28 100644 --- a/www/manager6/qemu/HardwareView.js +++ b/www/manager6/qemu/HardwareView.js @@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', { handler: run_move, }); + var import_btn = new Proxmox.button.Button({ + text: gettext('Import disk'), + hidden: Proxmox.UserName !== 'root@pam', + handler: function() { + var win = Ext.create('PVE.qemu.HDEdit', { + method: 'POST', + url: `/api2/extjs/${baseurl}`, + pveSelNode: me.pveSelNode, + isImport: true, + listeners: { + add: function(_, component) { + component.down('#sourceStorageSelector').show(); + component.down('#sourceStorageSelector').enable(); + component.down('#sourceFileSelector').enable(); + component.down('#sourceFileSelector').show(); + }, + }, + }); + win.on('destroy', me.reload, me); + win.show(); + }, + }); + var remove_btn = new Proxmox.button.Button({ text: gettext('Remove'), defaultText: gettext('Remove'), @@ -759,6 +782,7 @@ Ext.define('PVE.qemu.HardwareView', { edit_btn, resize_btn, move_btn, + import_btn, revert_btn, ], rows: rows, diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js new file mode 100644 index 00000000..a9a63fe3 --- /dev/null +++ b/www/manager6/qemu/ImportWizard.js @@ -0,0 +1,317 @@ +/*jslint confusion: true*/ +Ext.define('PVE.qemu.ImportWizard', { + extend: 'PVE.window.Wizard', + alias: 'widget.pveQemuImportWizard', + mixins: ['Proxmox.Mixin.CBind'], + + viewModel: { + data: { + nodename: '', + current: { + scsihw: '', + }, + }, + }, + + cbindData: { + nodename: undefined, + }, + + subject: gettext('Import Virtual Machine'), + + isImport: true, + + addDisk: function() { + const me = this; + const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window'); + wizard.down('pveQemuHDInputPanelCollection').addDisk(); + }, + + items: [ + { + xtype: 'inputpanel', + title: gettext('Import'), + itemId: 'importInputpanel', + 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: [ + { + 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: 'Absolute path to .ovf manifest on your PVE host', + 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 remote manifest'), + disabled: true, + handler: function() { + const inputpanel = this.up('#importInputpanel'); + 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('#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; + const devices = Object.keys(ovfdata); // e.g. ide0, sata2 + const hdcollection = wizard.down('pveQemuHDInputPanelCollection'); + hdcollection.removeAllDisks(); // does nothing if already empty + devices.forEach(device => hdcollection.addDisk(device, ovfdata[device])); + }, + failure: function(response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + ], + onGetValues: function(values) { + delete values.ovfTextfield; + 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: 'pveQemuHDInputPanelCollection', + title: gettext('Hard Disk'), + bind: { + nodename: '{nodename}', + }, + isCreate: true, + insideWizard: true, + }, + { + 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 params = wizard.getValues(); + + var nodename = params.nodename; + delete params.nodename; + delete params.delete; + if (Array.isArray(params.import_sources)) { + params.import_sources = params.import_sources.join('\0'); + } + + Proxmox.Utils.API2Request({ + url: `/nodes/${nodename}/qemu`, + waitMsgTarget: wizard, + method: 'POST', + params: params, + success: function() { + wizard.close(); + }, + failure: function(response) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + }); + }, + }, + ], +}); diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js index 8b930bbd..a3e3b690 100644 --- a/www/manager6/window/Wizard.js +++ b/www/manager6/window/Wizard.js @@ -261,6 +261,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.20.1 ^ permalink raw reply [flat|nested] 4+ messages in thread
* Re: [pve-devel] [PATCH] Add GUI to import disk & VM 2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger @ 2021-04-13 10:11 ` Oguz Bektas 0 siblings, 0 replies; 4+ messages in thread From: Oguz Bektas @ 2021-04-13 10:11 UTC (permalink / raw) To: Proxmox VE development discussion hi, tested along with the qemu-server patch, it seems to work but had some issues in some cases (especially with windows VMs) we discussed already off-list with dominic during the testing, but i'll just put these down here anyway: - the "Next" button should be grayed out until remote manifest is loaded, or alternatively attempt to load manifest when next button is pressed (instead of having a separate button) - in the disk menu, pressing the "+" adds a disk but doesn't increment the id, which can lead to having multiple disks with the same name - windows VMs need some special treatment when setting config options: -> machine: q35 -> use sata disk instead of ide -> also UEFI can be made default? in my tests the windows 10 installation didn't boot until adding a EFI disk and changing the BIOS to OVMF but besides these, it looks okay to me, and works as advertised :) On Mon, Apr 12, 2021 at 12:08:25PM +0200, Dominic Jäger wrote: > 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> > --- > v8: > - Adapt to new API > - Some small fixes > - Much renaming > > PVE/API2/Nodes.pm | 7 + > www/manager6/Makefile | 2 + > www/manager6/Workspace.js | 15 ++ > www/manager6/form/ControllerSelector.js | 15 ++ > www/manager6/node/CmdMenu.js | 13 + > www/manager6/qemu/HDEdit.js | 149 ++++++++++- > www/manager6/qemu/HDEditCollection.js | 263 ++++++++++++++++++++ > www/manager6/qemu/HardwareView.js | 24 ++ > www/manager6/qemu/ImportWizard.js | 317 ++++++++++++++++++++++++ > www/manager6/window/Wizard.js | 2 + > 10 files changed, 795 insertions(+), 12 deletions(-) > create mode 100644 www/manager6/qemu/HDEditCollection.js > create mode 100644 www/manager6/qemu/ImportWizard.js > > diff --git a/PVE/API2/Nodes.pm b/PVE/API2/Nodes.pm > index ba6621c6..1cee6cb5 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; > @@ -76,6 +77,11 @@ __PACKAGE__->register_method ({ > path => 'cpu', > }); > > +__PACKAGE__->register_method ({ > + subclass => "PVE::API2::Qemu::OVF", > + path => 'readovf', > +}); > + > __PACKAGE__->register_method ({ > subclass => "PVE::API2::LXC", > path => 'lxc', > @@ -2183,6 +2189,7 @@ __PACKAGE__->register_method ({ > return undef; > }}); > > + > # bash completion helper > > sub complete_templet_repo { > diff --git a/www/manager6/Makefile b/www/manager6/Makefile > index a2f7be6d..dbb85062 100644 > --- a/www/manager6/Makefile > +++ b/www/manager6/Makefile > @@ -196,8 +196,10 @@ JSSRC= \ > qemu/CmdMenu.js \ > qemu/Config.js \ > qemu/CreateWizard.js \ > + qemu/ImportWizard.js \ > qemu/DisplayEdit.js \ > qemu/HDEdit.js \ > + qemu/HDEditCollection.js \ > qemu/HDEfi.js \ > qemu/HDMove.js \ > qemu/HDResize.js \ > diff --git a/www/manager6/Workspace.js b/www/manager6/Workspace.js > index 0c1b9e0c..631739a0 100644 > --- a/www/manager6/Workspace.js > +++ b/www/manager6/Workspace.js > @@ -280,11 +280,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"), > + hidden: Proxmox.UserName !== 'root@pam', > + 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']); > } > }); > > @@ -332,6 +346,7 @@ Ext.define('PVE.StdWorkspace', { > }, > createVM, > createCT, > + importVM, > { > pack: 'end', > margin: '0 5 0 0', > diff --git a/www/manager6/form/ControllerSelector.js b/www/manager6/form/ControllerSelector.js > index 23c61159..f515b220 100644 > --- a/www/manager6/form/ControllerSelector.js > +++ b/www/manager6/form/ControllerSelector.js > @@ -68,6 +68,21 @@ clist_loop: > 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/node/CmdMenu.js b/www/manager6/node/CmdMenu.js > index b650bfa0..b66c7a6e 100644 > --- a/www/manager6/node/CmdMenu.js > +++ b/www/manager6/node/CmdMenu.js > @@ -29,6 +29,19 @@ Ext.define('PVE.node.CmdMenu', { > wiz.show(); > }, > }, > + { > + text: gettext("Import VM"), > + hidden: Proxmox.UserName !== 'root@pam', > + itemId: 'importvm', > + iconCls: 'fa fa-cube', > + handler: function() { > + const me = this.up('menu'); > + const wiz = Ext.create('PVE.qemu.ImportWizard', { > + nodename: me.nodename, > + }); > + wiz.show(); > + }, > + }, > { xtype: 'menuseparator' }, > { > text: gettext('Bulk Start'), > diff --git a/www/manager6/qemu/HDEdit.js b/www/manager6/qemu/HDEdit.js > index e22111bf..3af7e624 100644 > --- a/www/manager6/qemu/HDEdit.js > +++ b/www/manager6/qemu/HDEdit.js > @@ -58,6 +58,17 @@ 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 HDInputPanelCollection to get the selection for each HDInputPanel => Make > + names so that those within one HDInputPanel are equal, but different from other > + HDInputPanels > + */ > + getSourceTypeID() { > + return 'sourceType_' + this.id; > + }, > + > onGetValues: function(values) { > var me = this; > > @@ -70,6 +81,8 @@ Ext.define('PVE.qemu.HDInputPanel', { > } else if (me.isCreate) { > if (values.hdimage) { > me.drive.file = values.hdimage; > + } else if (me.isImport) { > + me.drive.file = `${values.hdstorage}:-1`; > } else { > me.drive.file = values.hdstorage + ":" + values.disksize; > } > @@ -83,13 +96,21 @@ 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); > - }); > + }); > > + const getSourceImageLocation = function() { > + const type = values[me.getSourceTypeID()]; > + return type === 'storage' ? values.sourceVolid : values.sourcePath; > + }; > + > + if (me.isImport) { > + params.import_sources = `${confid}=${getSourceImageLocation()}`; > + } > > params[confid] = PVE.Parser.printQemuDrive(me.drive); > > @@ -149,6 +170,10 @@ 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); > @@ -169,10 +194,15 @@ 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', { > + itemId: 'bussel', > 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 +214,7 @@ Ext.define('PVE.qemu.HDInputPanel', { > submitValue: false, > hidden: true, > }); > - me.column1.push(me.scsiController); > + controllerColumn.push(me.scsiController); > } > > if (me.unused) { > @@ -199,14 +229,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 +254,12 @@ Ext.define('PVE.qemu.HDInputPanel', { > }); > } > > + if (me.isImport) { > + me.column2.push({ > + xtype: 'box', > + autoEl: { tag: 'hr' }, > + }); > + } > me.column2.push( > { > xtype: 'CacheTypeSelector', > @@ -231,6 +274,84 @@ Ext.define('PVE.qemu.HDInputPanel', { > name: 'discard', > }, > ); > + if (me.isImport) { > + me.column1.unshift( > + { > + xtype: 'radiofield', > + itemId: 'sourceRadioStorage', > + name: me.getSourceTypeID(), > + inputValue: 'storage', > + boxLabel: gettext('Use a storage as source'), > + hidden: Proxmox.UserName !== 'root@pam', > + checked: true, > + listeners: { > + change: (_, newValue) => { > + me.down('#sourceStorageSelector').setHidden(!newValue); > + me.down('#sourceStorageSelector').setDisabled(!newValue); > + me.down('#sourceFileSelector').setHidden(!newValue); > + me.down('#sourceFileSelector').setDisabled(!newValue); > + }, > + }, > + }, { > + xtype: 'pveStorageSelector', > + itemId: 'sourceStorageSelector', > + name: 'inputImageStorage', > + nodename: me.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', > + nodename: me.nodename, > + storageContent: 'images', > + hidden: true, > + disabled: true, > + fieldLabel: gettext('Source 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"), > + }, > + }, { > + xtype: 'radiofield', > + itemId: 'sourceRadioPath', > + name: me.getSourceTypeID(), > + inputValue: 'path', > + boxLabel: gettext('Use an absolute path as source'), > + hidden: Proxmox.UserName !== 'root@pam', > + listeners: { > + change: (_, newValue) => { > + me.down('#sourcePathTextfield').setHidden(!newValue); > + me.down('#sourcePathTextfield').setDisabled(!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: function(insertedText) { > + return insertedText.startsWith('/') || > + insertedText.startsWith('http') || > + gettext('Must be an absolute path or URL'); > + }, > + }, > + ); > + } > > me.advancedColumn1.push( > { > @@ -373,13 +494,17 @@ Ext.define('PVE.qemu.HDEdit', { > nodename: nodename, > unused: unused, > 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 + ')'; > } > diff --git a/www/manager6/qemu/HDEditCollection.js b/www/manager6/qemu/HDEditCollection.js > new file mode 100644 > index 00000000..33f6193a > --- /dev/null > +++ b/www/manager6/qemu/HDEditCollection.js > @@ -0,0 +1,263 @@ > +Ext.define('PVE.qemu.HDInputPanelCollection', { > + extend: 'Proxmox.panel.InputPanel', > + alias: 'widget.pveQemuHDInputPanelCollection', > + > + insideWizard: false, > + > + hiddenDisks: [], > + > + leftColumnRatio: 0.25, > + > + column1: [ > + { > + // Adding to the panelContainer 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) { > + 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('Target device'), > + dataIndex: 'device', > + flex: 1, > + resizable: false, > + }, > + ], > + listeners: { > + select: function(_, record) { > + this.up('pveQemuHDInputPanelCollection') > + .down('#panelContainer') > + .setActiveItem(record.data.panel); > + }, > + }, > + anchor: '100% 90%', > + selectLast: function() { > + this.setSelection(this.store.getLast()); > + }, > + }, { > + xtype: 'container', > + layout: 'hbox', > + center: true, > + defaults: { > + margin: '5', > + xtype: 'button', > + }, > + items: [ > + { > + iconCls: 'fa fa-plus-circle', > + itemId: 'addDisk', > + handler: function(button) { > + button.up('pveQemuHDInputPanelCollection').addDisk(); > + }, > + }, { > + iconCls: 'fa fa-trash-o', > + itemId: 'removeDisk', > + handler: function(button) { > + button.up('pveQemuHDInputPanelCollection').removeCurrentDisk(); > + }, > + }, > + ], > + }, > + ], > + 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('importwizard_diskstorage'); > + store.add({ device: newPanel.getDevice(), panel: newPanel }); > + container.setActiveItem(newPanel); > + }, > + remove: function(panelContainer, HDInputPanel, eOpts) { > + const store = Ext.getStore('importwizard_diskstorage'); > + store.removeByPanel(HDInputPanel); > + if (panelContainer.items.getCount() > 0) { > + panelContainer.setActiveItem(0); > + } > + }, > + }, > + defaultItem: { > + xtype: 'pveQemuHDInputPanel', > + bind: { > + nodename: '{nodename}', > + }, > + isCreate: true, > + isImport: true, > + insideWizard: true, > + setNodename: function(nodename) { > + this.down('#hdstorage').setNodename(nodename); > + this.down('#hdimage').setStorage(undefined, nodename); > + this.down('#sourceStorageSelector').setNodename(nodename); > + this.down('#sourceFileSelector').setNodename(nodename); > + }, > + listeners: { > + // newPanel ... this cloned + added defaultItem > + added: function(newPanel) { > + Ext.Array.each(newPanel.down('pveControllerSelector').query('field'), > + function(field) { > + // Add here because the fields don't exist earlier > + field.on('change', function() { > + const store = Ext.getStore('importwizard_diskstorage'); > + > + // find by panel object because it is unique > + const recordIndex = store.findBy(record => > + record.data.panel === field.up('pveQemuHDInputPanel'), > + ); > + const controllerSelector = field.up('pveControllerSelector'); > + const newControllerAndId = controllerSelector.getValuesAsString(); > + > + store.getAt(recordIndex).set('device', newControllerAndId); > + }); > + }, > + ); > + const wizard = this.up('pveQemuImportWizard'); > + Ext.Array.each(this.query('field'), function(field) { > + field.on('change', wizard.validcheck); > + field.on('validitychange', wizard.validcheck); > + }); > + }, > + }, > + validator: function() { > + var valid = true; > + var fields = this.query('field, fieldcontainer'); > + Ext.Array.each(fields, function(field) { > + // Note: not all fielcontainer have isValid() > + 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 sourcePathTextfield > + addDisk(device, path) { > + const item = Ext.clone(this.defaultItem); > + const added = this.add(item); > + // values in the storage will be updated by listeners > + 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(); > + } > + > + const sp = Ext.state.Manager.getProvider(); > + const advanced_checkbox = sp.get('proxmox-advanced-cb'); > + added.setAdvancedVisible(advanced_checkbox); > + > + if (device) { > + added.down('pveControllerSelector').setValue(device); > + } > + 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); > + leftColumnPanel.setFlex(me.leftColumnRatio); > + // any other panel because this has no height yet > + const panelHeight = me.up('tabpanel').items.get(0).getHeight(); > + leftColumnPanel.setHeight(panelHeight); > + }, > + > + setNodename: function(nodename) { > + this.nodename = nodename; > + }, > + > + listeners: { > + afterrender: function() { > + const store = Ext.getStore('importwizard_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 HDInputPanel 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(); > + }, > +}); > diff --git a/www/manager6/qemu/HardwareView.js b/www/manager6/qemu/HardwareView.js > index 98352e3f..be4e2d28 100644 > --- a/www/manager6/qemu/HardwareView.js > +++ b/www/manager6/qemu/HardwareView.js > @@ -431,6 +431,29 @@ Ext.define('PVE.qemu.HardwareView', { > handler: run_move, > }); > > + var import_btn = new Proxmox.button.Button({ > + text: gettext('Import disk'), > + hidden: Proxmox.UserName !== 'root@pam', > + handler: function() { > + var win = Ext.create('PVE.qemu.HDEdit', { > + method: 'POST', > + url: `/api2/extjs/${baseurl}`, > + pveSelNode: me.pveSelNode, > + isImport: true, > + listeners: { > + add: function(_, component) { > + component.down('#sourceStorageSelector').show(); > + component.down('#sourceStorageSelector').enable(); > + component.down('#sourceFileSelector').enable(); > + component.down('#sourceFileSelector').show(); > + }, > + }, > + }); > + win.on('destroy', me.reload, me); > + win.show(); > + }, > + }); > + > var remove_btn = new Proxmox.button.Button({ > text: gettext('Remove'), > defaultText: gettext('Remove'), > @@ -759,6 +782,7 @@ Ext.define('PVE.qemu.HardwareView', { > edit_btn, > resize_btn, > move_btn, > + import_btn, > revert_btn, > ], > rows: rows, > diff --git a/www/manager6/qemu/ImportWizard.js b/www/manager6/qemu/ImportWizard.js > new file mode 100644 > index 00000000..a9a63fe3 > --- /dev/null > +++ b/www/manager6/qemu/ImportWizard.js > @@ -0,0 +1,317 @@ > +/*jslint confusion: true*/ > +Ext.define('PVE.qemu.ImportWizard', { > + extend: 'PVE.window.Wizard', > + alias: 'widget.pveQemuImportWizard', > + mixins: ['Proxmox.Mixin.CBind'], > + > + viewModel: { > + data: { > + nodename: '', > + current: { > + scsihw: '', > + }, > + }, > + }, > + > + cbindData: { > + nodename: undefined, > + }, > + > + subject: gettext('Import Virtual Machine'), > + > + isImport: true, > + > + addDisk: function() { > + const me = this; > + const wizard = me.xtype === 'pveQemuImportWizard' ? me : me.up('window'); > + wizard.down('pveQemuHDInputPanelCollection').addDisk(); > + }, > + > + items: [ > + { > + xtype: 'inputpanel', > + title: gettext('Import'), > + itemId: 'importInputpanel', > + 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: [ > + { > + 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: 'Absolute path to .ovf manifest on your PVE host', > + 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 remote manifest'), > + disabled: true, > + handler: function() { > + const inputpanel = this.up('#importInputpanel'); > + 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('#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; > + const devices = Object.keys(ovfdata); // e.g. ide0, sata2 > + const hdcollection = wizard.down('pveQemuHDInputPanelCollection'); > + hdcollection.removeAllDisks(); // does nothing if already empty > + devices.forEach(device => hdcollection.addDisk(device, ovfdata[device])); > + }, > + failure: function(response) { > + Ext.Msg.alert(gettext('Error'), response.htmlStatus); > + }, > + }); > + }, > + }, > + ], > + onGetValues: function(values) { > + delete values.ovfTextfield; > + 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: 'pveQemuHDInputPanelCollection', > + title: gettext('Hard Disk'), > + bind: { > + nodename: '{nodename}', > + }, > + isCreate: true, > + insideWizard: true, > + }, > + { > + 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 params = wizard.getValues(); > + > + var nodename = params.nodename; > + delete params.nodename; > + delete params.delete; > + if (Array.isArray(params.import_sources)) { > + params.import_sources = params.import_sources.join('\0'); > + } > + > + Proxmox.Utils.API2Request({ > + url: `/nodes/${nodename}/qemu`, > + waitMsgTarget: wizard, > + method: 'POST', > + params: params, > + success: function() { > + wizard.close(); > + }, > + failure: function(response) { > + Ext.Msg.alert(gettext('Error'), response.htmlStatus); > + }, > + }); > + }, > + }, > + ], > +}); > diff --git a/www/manager6/window/Wizard.js b/www/manager6/window/Wizard.js > index 8b930bbd..a3e3b690 100644 > --- a/www/manager6/window/Wizard.js > +++ b/www/manager6/window/Wizard.js > @@ -261,6 +261,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.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] 4+ messages in thread
* Re: [pve-devel] [PATCH] Add API for VM import 2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger 2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger @ 2021-04-22 20:06 ` Thomas Lamprecht 1 sibling, 0 replies; 4+ messages in thread From: Thomas Lamprecht @ 2021-04-22 20:06 UTC (permalink / raw) To: Proxmox VE development discussion, Dominic Jäger On 12.04.21 12:08, Dominic Jäger wrote: > 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> > --- > v8: > - Fabian moved the import functions into the existing create_vm / update_vm_api > - Dropped the separate API endpoints & import lock > for the record, we talked for postponing this to 7.0 to improve convergence/synergy (gosh, feels like marketing with those terms ;)) between the create and import wizard. ^ permalink raw reply [flat|nested] 4+ messages in thread
end of thread, other threads:[~2021-04-22 20:06 UTC | newest] Thread overview: 4+ messages (download: mbox.gz / follow: Atom feed) -- links below jump to the message on this page -- 2021-04-12 10:08 [pve-devel] [PATCH] Add API for VM import Dominic Jäger 2021-04-12 10:08 ` [pve-devel] [PATCH] Add GUI to import disk & VM Dominic Jäger 2021-04-13 10:11 ` Oguz Bektas 2021-04-22 20:06 ` [pve-devel] [PATCH] Add API for VM import Thomas Lamprecht
This is a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox