diff --git a/www/manager6/window/Migrate.js b/www/manager6/window/Migrate.js
new file mode 100644
index 0000000..8cac54e
--- /dev/null
+++ b/www/manager6/window/Migrate.js
@@ -0,0 +1,740 @@
+Ext.define('PVE.window.Migrate', {
+ extend: 'Ext.window.Window',
+
+ vmtype: undefined,
+ nodename: undefined,
+ vmid: undefined,
+ vmname: undefined,
+ maxHeight: 450,
+
+ viewModel: {
+ data: {
+ vmid: undefined,
+ nodename: undefined,
+ vmtype: undefined,
+ running: false,
+ qemu: {
+ onlineHelp: 'qm_migration',
+ commonName: 'VM',
+ },
+ lxc: {
+ onlineHelp: 'pct_migration',
+ commonName: 'CT',
+ },
+ migration: {
+ possible: true,
+ preconditions: [],
+ 'with-local-disks': 0,
+ mode: undefined,
+ allowedNodes: undefined,
+ overwriteLocalResourceCheck: false,
+ hasLocalResources: false,
+ withConntrackState: true,
+ bothHaveDbusVmstate: false,
+ },
+ },
+ formulas: {
+ setMigrationMode: function (get) {
+ if (get('running')) {
+ if (get('vmtype') === 'qemu') {
+ return gettext('Online');
+ } else {
+ return gettext('Restart Mode');
+ }
+ } else {
+ return gettext('Offline');
+ }
+ },
+ setStorageselectorHidden: function (get) {
+ if (get('migration.with-local-disks') && get('running')) {
+ return false;
+ } else {
+ return true;
+ }
+ },
+ setLocalResourceCheckboxHidden: function (get) {
+ if (
+ get('running') ||
+ !get('migration.hasLocalResources') ||
+ Proxmox.UserName !== 'root@pam'
+ ) {
+ return true;
+ } else {
+ return false;
+ }
+ },
+ conntrackStateCheckboxHidden: (get) =>
+ !get('running') ||
+ get('vmtype') !== 'qemu' ||
+ !get('migration.bothHaveDbusVmstate'),
+ },
+ },
+
+ controller: {
+ xclass: 'Ext.app.ViewController',
+ control: {
+ 'panel[reference=formPanel]': {
+ validityChange: function (panel, isValid) {
+ this.getViewModel().set('migration.possible', isValid);
+ this.checkMigratePreconditions();
+ },
+ },
+ },
+
+ init: function (view) {
+ var me = this,
+ vm = view.getViewModel();
+
+ if (!view.nodename) {
+ throw 'missing custom view config: nodename';
+ }
+ vm.set('nodename', view.nodename);
+
+ if (!view.vmid) {
+ throw 'missing custom view config: vmid';
+ }
+ vm.set('vmid', view.vmid);
+
+ if (!view.vmtype) {
+ throw 'missing custom view config: vmtype';
+ }
+ vm.set('vmtype', view.vmtype);
+
+ let title = Ext.String.format(
+ '{0} {1} {2}',
+ gettext('Migrate'),
+ vm.get(view.vmtype).commonName,
+ PVE.Utils.getFormattedGuestIdentifier(view.vmid, view.vmname),
+ );
+ view.setTitle(title);
+
+ me.lookup('proxmoxHelpButton').setHelpConfig({
+ onlineHelp: vm.get(view.vmtype).onlineHelp,
+ });
+ me.lookup('formPanel').isValid();
+ },
+
+ onTargetChange: function (nodeSelector) {
+ // Always display the storages of the currently selected migration target
+ this.lookup('pveDiskStorageSelector').setNodename(nodeSelector.value);
+ this.checkMigratePreconditions(true);
+ },
+
+ startMigration: function () {
+ var me = this,
+ view = me.getView(),
+ vm = me.getViewModel();
+
+ var values = me.lookup('formPanel').getValues();
+ var params = {
+ target: values.target,
+ };
+
+ if (vm.get('migration.mode')) {
+ params[vm.get('migration.mode')] = 1;
+ }
+ if (vm.get('migration.with-local-disks')) {
+ params['with-local-disks'] = 1;
+ }
+ //offline migration to a different storage currently might fail at a late stage
+ //(i.e. after some disks have been moved), so don't expose it yet in the GUI
+ if (vm.get('migration.with-local-disks') && vm.get('running') && values.targetstorage) {
+ params.targetstorage = values.targetstorage;
+ }
+
+ if (vm.get('migration.overwriteLocalResourceCheck')) {
+ params.force = 1;
+ }
+
+ if (vm.get('migration.bothHaveDbusVmstate') && vm.get('migration.withConntrackState')) {
+ params['with-conntrack-state'] = 1;
+ }
+
+ Proxmox.Utils.API2Request({
+ params: params,
+ url:
+ '/nodes/' +
+ vm.get('nodename') +
+ '/' +
+ vm.get('vmtype') +
+ '/' +
+ vm.get('vmid') +
+ '/migrate',
+ waitMsgTarget: view,
+ method: 'POST',
+ failure: function (response, opts) {
+ Ext.Msg.alert(Proxmox.Utils.errorText, response.htmlStatus);
+ },
+ success: function (response, options) {
+ var upid = response.result.data;
+ var extraTitle = Ext.String.format(
+ ' ({0} ---> {1})',
+ vm.get('nodename'),
+ params.target,
+ );
+
+ Ext.create('Proxmox.window.TaskViewer', {
+ upid: upid,
+ extraTitle: extraTitle,
+ }).show();
+
+ view.close();
+ },
+ });
+ },
+
+ checkMigratePreconditions: async function (resetMigrationPossible) {
+ var me = this,
+ vm = me.getViewModel();
+
+ var vmrec = PVE.data.ResourceStore.findRecord(
+ 'vmid',
+ vm.get('vmid'),
+ 0,
+ false,
+ false,
+ true,
+ );
+ if (vmrec && vmrec.data && vmrec.data.running) {
+ vm.set('running', true);
+ }
+
+ me.lookup('pveNodeSelector').disallowedNodes = [vm.get('nodename')];
+
+ if (vm.get('vmtype') === 'qemu') {
+ await me.checkQemuPreconditions(resetMigrationPossible);
+ } else {
+ await me.checkLxcPreconditions(resetMigrationPossible);
+ }
+
+ // Only allow nodes where the local storage is available in case of offline migration
+ // where storage migration is not possible
+ me.lookup('pveNodeSelector').allowedNodes = vm.get('migration.allowedNodes');
+
+ me.lookup('formPanel').isValid();
+ },
+
+ checkQemuPreconditions: async function (resetMigrationPossible) {
+ let me = this,
+ vm = me.getViewModel(),
+ migrateStats;
+
+ if (vm.get('running')) {
+ vm.set('migration.mode', 'online');
+ }
+
+ try {
+ if (
+ me.fetchingNodeMigrateInfo &&
+ me.fetchingNodeMigrateInfo === vm.get('nodename')
+ ) {
+ return;
+ }
+ me.fetchingNodeMigrateInfo = vm.get('nodename');
+ let { result } = await Proxmox.Async.api2({
+ url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`,
+ method: 'GET',
+ });
+ migrateStats = result.data;
+ } catch (error) {
+ if (error?.result?.status !== 501) {
+ Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus);
+ }
+ me.fetchingNodeMigrateInfo = false;
+ return;
+ }
+
+ const target = me.lookup('pveNodeSelector').value;
+ let targetCapabilities = {};
+
+ try {
+ const { result } = await Proxmox.Async.api2({
+ url: `/nodes/${target}/capabilities/qemu/migration`,
+ method: 'GET',
+ });
+ targetCapabilities = result.data;
+ } catch (err) {
+ // Only emit a warning in the case the target node does not (yet) support the
+ // `capabilites/qemu/migration` endpoint and simply treat all features as unsupported.
+ console.warn(`failed to query /capabilites/qemu/migration on '${target}':`, err);
+ }
+
+ me.fetchingNodeMigrateInfo = false;
+
+ if (migrateStats.running) {
+ vm.set('running', true);
+ }
+ // Get migration object from viewmodel to prevent to many bind callbacks
+ let migration = vm.get('migration');
+ if (resetMigrationPossible) {
+ migration.possible = true;
+ }
+ migration.preconditions = [];
+ let disallowed = migrateStats.not_allowed_nodes?.[target] ?? {};
+
+ if (migrateStats.allowed_nodes && !vm.get('running')) {
+ migration.allowedNodes = migrateStats.allowed_nodes;
+ if (target.length && !migrateStats.allowed_nodes.includes(target)) {
+ if (disallowed.unavailable_storages !== undefined) {
+ let missingStorages = disallowed.unavailable_storages.join(', ');
+ const text = Ext.String.format(
+ gettext(
+ 'Storage(s) ({0}) not available on selected target. Start VM to use live storage migration or select other target node.',
+ ),
+ missingStorages,
+ );
+
+ migration.possible = false;
+ migration.preconditions.push({ text, severity: 'error' });
+ }
+ }
+ }
+
+ if (disallowed['unavailable-resources'] !== undefined) {
+ let unavailableResources = disallowed['unavailable-resources'].join(', ');
+ const text = Ext.String.format(
+ gettext('Mapped Resources ({0}) not available on selected target.'),
+ unavailableResources,
+ );
+
+ migration.possible = false;
+ migration.preconditions.push({ text, severity: 'error' });
+ }
+
+ let blockingResources = [];
+ let mappedResources = migrateStats['mapped-resource-info'] ?? {};
+
+ for (const res of migrateStats.local_resources) {
+ if (!mappedResources[res]) {
+ blockingResources.push(res);
+ }
+ }
+
+ if (blockingResources.length) {
+ migration.hasLocalResources = true;
+ if (!migration.overwriteLocalResourceCheck || vm.get('running')) {
+ const text = Ext.String.format(
+ gettext('Cannot migrate VM with local resources: {0}'),
+ blockingResources.join(', '),
+ );
+
+ migration.possible = false;
+ migration.preconditions.push({ text, severity: 'error' });
+ } else {
+ const text = Ext.String.format(
+ gettext(
+ 'Migrating VM with local resources: {0}. This might fail if the resources are not available on the target node.',
+ ),
+ blockingResources.join(', '),
+ );
+
+ migration.preconditions.push({ text, severity: 'warning' });
+ }
+ }
+
+ if (vm.get('running')) {
+ let allowed = [];
+ let notAllowed = [];
+ for (const [key, resource] of Object.entries(mappedResources)) {
+ if (resource['live-migration']) {
+ allowed.push(key);
+ } else {
+ notAllowed.push(key);
+ }
+ }
+ if (notAllowed.length > 0) {
+ const text = Ext.String.format(
+ gettext('Cannot migrate running VM with mapped resources: {0}'),
+ notAllowed.join(', '),
+ );
+
+ migration.possible = false;
+ migration.preconditions.push({ text, severity: 'error' });
+ } else if (allowed.length > 0) {
+ const text = Ext.String.format(
+ gettext(
+ 'Live-migrating running VM with mapped resources (Experimental): {0}',
+ ),
+ allowed.join(', '),
+ );
+
+ migration.preconditions.push({ text, severity: 'warning' });
+ }
+ }
+
+ if (migrateStats.local_disks.length) {
+ migrateStats.local_disks.forEach(function (disk) {
+ if (disk.cdrom && disk.cdrom === 1) {
+ if (!disk.volid.includes('vm-' + vm.get('vmid') + '-cloudinit')) {
+ migration.possible = false;
+ migration.preconditions.push({
+ text: gettext('Cannot migrate VM with local CD/DVD'),
+ severity: 'error',
+ });
+ }
+ } else {
+ let size = disk.size
+ ? '(' + Proxmox.Utils.render_size(disk.size) + ')'
+ : '';
+ const text = Ext.String.format(
+ gettext('Migration with local disk might take long: {0} {1}'),
+ disk.volid,
+ size,
+ );
+
+ migration['with-local-disks'] = 1;
+ migration.preconditions.push({ text, severity: 'warning' });
+ }
+ });
+ }
+
+ migration.bothHaveDbusVmstate =
+ migrateStats['has-dbus-vmstate'] && targetCapabilities['has-dbus-vmstate'];
+ if (vm.get('running')) {
+ if (migration.withConntrackState && !migrateStats['has-dbus-vmstate']) {
+ migration.preconditions.push({
+ text: gettext(
+ 'Cannot migrate conntrack state, source node is lacking support.',
+ ),
+ // user cannot really do anything about this, do not bother with scaring them!
+ severity: 'info',
+ });
+ }
+ if (migration.withConntrackState && !targetCapabilities['has-dbus-vmstate']) {
+ migration.preconditions.push({
+ text: gettext(
+ 'Cannot migrate conntrack state, target node is lacking support. Active network connections might get dropped.',
+ ),
+ severity: 'warning',
+ });
+ }
+
+ if (migration.bothHaveDbusVmstate && !migration.withConntrackState) {
+ migration.preconditions.push({
+ text: gettext(
+ 'Conntrack state migration disabled. Active network connections might get dropped.',
+ ),
+ severity: 'warning',
+ });
+ }
+ }
+
+ let blockingHAResources = disallowed['blocking-ha-resources'] ?? [];
+ if (blockingHAResources.length) {
+ migration.possible = false;
+
+ for (const { sid, cause } of blockingHAResources) {
+ let reasonText;
+ if (cause === 'resource-affinity') {
+ reasonText = Ext.String.format(
+ gettext(
+ 'HA resource {0} with negative affinity to VM on selected target node',
+ ),
+ sid,
+ );
+ } else if (cause === 'node-affinity') {
+ reasonText = Ext.String.format(
+ gettext('HA resource {0} is not allowed on the selected target node'),
+ sid,
+ );
+ } else {
+ reasonText = Ext.String.format(
+ gettext('blocking HA resource {0} on selected target node'),
+ sid,
+ );
+ }
+
+ migration.preconditions.push({
+ severity: 'error',
+ text: Ext.String.format(
+ gettext('Cannot migrate VM, because {0}.'),
+ reasonText,
+ ),
+ });
+ }
+ }
+
+ let dependentHAResources = migrateStats['dependent-ha-resources'];
+ if (dependentHAResources !== undefined) {
+ for (const sid of dependentHAResources) {
+ const text = Ext.String.format(
+ gettext(
+ 'HA resource {0} with positive affinity to VM is also migrated to selected target node.',
+ ),
+ sid,
+ );
+
+ migration.preconditions.push({ text, severity: 'warning' });
+ }
+ }
+
+ vm.set('migration', migration);
+ },
+ checkLxcPreconditions: async function (resetMigrationPossible) {
+ let me = this;
+ let vm = me.getViewModel();
+ let migrateStats;
+
+ if (vm.get('running')) {
+ vm.set('migration.mode', 'restart');
+ }
+
+ try {
+ if (
+ me.fetchingNodeMigrateInfo &&
+ me.fetchingNodeMigrateInfo === vm.get('nodename')
+ ) {
+ return;
+ }
+ me.fetchingNodeMigrateInfo = vm.get('nodename');
+ let { result } = await Proxmox.Async.api2({
+ url: `/nodes/${vm.get('nodename')}/${vm.get('vmtype')}/${vm.get('vmid')}/migrate`,
+ method: 'GET',
+ });
+ migrateStats = result.data;
+ me.fetchingNodeMigrateInfo = false;
+ } catch (error) {
+ if (error?.result?.status !== 501) {
+ Ext.Msg.alert(Proxmox.Utils.errorText, error.htmlStatus);
+ }
+ me.fetchingNodeMigrateInfo = false;
+ return;
+ }
+
+ if (migrateStats.running) {
+ vm.set('running', true);
+ }
+
+ // Get migration object from viewmodel to prevent to many bind callbacks
+ let migration = vm.get('migration');
+ if (resetMigrationPossible) {
+ migration.possible = true;
+ }
+ migration.preconditions = [];
+ let targetNode = me.lookup('pveNodeSelector').value;
+ let disallowed = migrateStats['not-allowed-nodes']?.[targetNode] ?? {};
+
+ let blockingHAResources = disallowed['blocking-ha-resources'] ?? [];
+ if (blockingHAResources.length) {
+ migration.possible = false;
+
+ for (const { sid, cause } of blockingHAResources) {
+ let reasonText;
+ if (cause === 'resource-affinity') {
+ reasonText = Ext.String.format(
+ gettext(
+ 'HA resource {0} with negative affinity to container on selected target node',
+ ),
+ sid,
+ );
+ } else if (cause === 'node-affinity') {
+ reasonText = Ext.String.format(
+ gettext('HA resource {0} is not allowed on the selected target node'),
+ sid,
+ );
+ } else {
+ reasonText = Ext.String.format(
+ gettext('blocking HA resource {0} on selected target node'),
+ sid,
+ );
+ }
+
+ migration.preconditions.push({
+ severity: 'error',
+ text: Ext.String.format(
+ gettext('Cannot migrate container, because {0}.'),
+ reasonText,
+ ),
+ });
+ }
+ }
+
+ let dependentHAResources = migrateStats['dependent-ha-resources'];
+ if (dependentHAResources !== undefined) {
+ for (const sid of dependentHAResources) {
+ const text = Ext.String.format(
+ gettext(
+ 'HA resource {0} with positive affinity to container is also migrated to selected target node.',
+ ),
+ sid,
+ );
+
+ migration.preconditions.push({ text, severity: 'warning' });
+ }
+ }
+
+ vm.set('migration', migration);
+ },
+ },
+
+ width: 600,
+ modal: true,
+ layout: {
+ type: 'vbox',
+ align: 'stretch',
+ },
+ border: false,
+ items: [
+ {
+ xtype: 'form',
+ reference: 'formPanel',
+ bodyPadding: 10,
+ border: false,
+ layout: 'hbox',
+ items: [
+ {
+ xtype: 'container',
+ flex: 1,
+ items: [
+ {
+ xtype: 'displayfield',
+ name: 'source',
+ fieldLabel: gettext('Source node'),
+ bind: {
+ value: '{nodename}',
+ },
+ },
+ {
+ xtype: 'displayfield',
+ reference: 'migrationMode',
+ fieldLabel: gettext('Mode'),
+ bind: {
+ value: '{setMigrationMode}',
+ },
+ },
+ ],
+ },
+ {
+ xtype: 'container',
+ flex: 1,
+ items: [
+ {
+ xtype: 'pveNodeSelector',
+ reference: 'pveNodeSelector',
+ name: 'target',
+ fieldLabel: gettext('Target node'),
+ allowBlank: false,
+ disallowedNodes: undefined,
+ onlineValidator: true,
+ listeners: {
+ change: 'onTargetChange',
+ },
+ },
+ {
+ xtype: 'pveStorageSelector',
+ reference: 'pveDiskStorageSelector',
+ name: 'targetstorage',
+ fieldLabel: gettext('Target storage'),
+ storageContent: 'images',
+ allowBlank: true,
+ autoSelect: false,
+ emptyText: gettext('Current layout'),
+ bind: {
+ hidden: '{setStorageselectorHidden}',
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'overwriteLocalResourceCheck',
+ fieldLabel: gettext('Force'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext('Overwrite local resources unavailable check'),
+ },
+ bind: {
+ hidden: '{setLocalResourceCheckboxHidden}',
+ value: '{migration.overwriteLocalResourceCheck}',
+ },
+ listeners: {
+ change: {
+ fn: 'checkMigratePreconditions',
+ extraArg: true,
+ },
+ },
+ },
+ {
+ xtype: 'proxmoxcheckbox',
+ name: 'withConntrackState',
+ // TRANSLATORS: See https://www.kernel.org/doc/html/next/networking/netlink_spec/conntrack.html
+ fieldLabel: gettext('Conntrack state'),
+ autoEl: {
+ tag: 'div',
+ 'data-qtip': gettext(
+ 'Enables live migration of conntrack entries for this VM.',
+ ),
+ },
+ bind: {
+ hidden: '{conntrackStateCheckboxHidden}',
+ value: '{migration.withConntrackState}',
+ },
+ listeners: {
+ change: {
+ fn: 'checkMigratePreconditions',
+ extraArg: true,
+ },
+ },
+ },
+ ],
+ },
+ ],
+ },
+ {
+ xtype: 'gridpanel',
+ reference: 'preconditionGrid',
+ selectable: false,
+ flex: 1,
+ columns: [
+ {
+ text: '',
+ dataIndex: 'severity',
+ renderer: function (v) {
+ switch (v) {
+ case 'warning':
+ return ' ';
+ case 'error':
+ return '';
+ case 'info':
+ return '';
+ default:
+ return v;
+ }
+ },
+ width: 35,
+ },
+ {
+ text: 'Info',
+ dataIndex: 'text',
+ cellWrap: true,
+ flex: 1,
+ },
+ ],
+ bind: {
+ hidden: '{!migration.preconditions.length}',
+ store: {
+ fields: ['severity', 'text'],
+ data: '{migration.preconditions}',
+ sorters: 'text',
+ },
+ },
+ },
+ ],
+ buttons: [
+ {
+ xtype: 'proxmoxHelpButton',
+ reference: 'proxmoxHelpButton',
+ onlineHelp: 'pct_migration',
+ listenToGlobalEvent: false,
+ hidden: false,
+ },
+ '->',
+ {
+ xtype: 'button',
+ reference: 'submitButton',
+ text: gettext('Migrate'),
+ handler: 'startMigration',
+ bind: {
+ disabled: '{!migration.possible}',
+ },
+ },
+ ],
+});