From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 933461FF13B for ; Wed, 22 Apr 2026 15:40:32 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id B19BA2078D; Wed, 22 Apr 2026 15:40:29 +0200 (CEST) From: Hannes Laimer To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup v8 12/13] ui: add move namespace action Date: Wed, 22 Apr 2026 15:39:50 +0200 Message-ID: <20260422133951.192862-13-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260422133951.192862-1-h.laimer@proxmox.com> References: <20260422133951.192862-1-h.laimer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1776865131876 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.081 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: 4XBD7OJ6VTARNCQGBB5QL6M2WY2ODIML X-Message-ID-Hash: 4XBD7OJ6VTARNCQGBB5QL6M2WY2ODIML X-MailFrom: h.laimer@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox Backup Server development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Add a "Move" action to the namespace action column. Opens a dialog where the user selects a new parent namespace and name, then submits a POST to the move-namespace API endpoint. The source namespace and its descendants are excluded from the parent selector to prevent cycles. An advanced section exposes the max-depth and delete-source options. Signed-off-by: Hannes Laimer --- www/Makefile | 1 + www/datastore/Content.js | 35 ++++++++- www/form/NamespaceSelector.js | 11 +++ www/window/NamespaceMove.js | 136 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 www/window/NamespaceMove.js diff --git a/www/Makefile b/www/Makefile index 06441c02..bad243cf 100644 --- a/www/Makefile +++ b/www/Makefile @@ -80,6 +80,7 @@ JSSRC= \ window/CreateDirectory.js \ window/DataStoreEdit.js \ window/NamespaceEdit.js \ + window/NamespaceMove.js \ window/MaintenanceOptions.js \ window/NotesEdit.js \ window/NotificationThresholds.js \ diff --git a/www/datastore/Content.js b/www/datastore/Content.js index 548f5670..52248756 100644 --- a/www/datastore/Content.js +++ b/www/datastore/Content.js @@ -668,6 +668,26 @@ Ext.define('PBS.DataStoreContent', { }); }, + moveNS: function () { + let me = this; + let view = me.getView(); + if (!view.namespace || view.namespace === '') { + return; + } + let win = Ext.create('PBS.window.NamespaceMove', { + datastore: view.datastore, + namespace: view.namespace, + taskDone: (success) => { + if (success) { + let newNs = win.getNewNamespace(); + view.down('pbsNamespaceSelector').store?.load(); + me.nsChange(null, newNs); + } + }, + }); + win.show(); + }, + moveGroup: function (data) { let me = this; let view = me.getView(); @@ -686,6 +706,8 @@ Ext.define('PBS.DataStoreContent', { let me = this; if (data.ty === 'group') { me.moveGroup(data); + } else if (data.ty === 'ns') { + me.moveNS(); } }, @@ -1114,13 +1136,22 @@ Ext.define('PBS.DataStoreContent', { if (data.ty === 'group') { return Ext.String.format(gettext("Move group '{0}'"), v); } - return ''; + return Ext.String.format(gettext("Move namespace '{0}'"), v); }, getClass: (v, m, { data }) => { if (data.ty === 'group') { return 'fa fa-arrows'; } + if (data.ty === 'ns' && !data.isRootNS && data.ns === undefined) { + return 'fa fa-arrows'; + } return 'pmx-hidden'; }, - isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group', + isActionDisabled: (v, r, c, i, { data }) => { + if (data.ty === 'group') { return false; } + if (data.ty === 'ns' && !data.isRootNS && data.ns === undefined) { + return false; + } + return true; + }, }, { handler: 'onChangeOwner', diff --git a/www/form/NamespaceSelector.js b/www/form/NamespaceSelector.js index ddf68254..d349b568 100644 --- a/www/form/NamespaceSelector.js +++ b/www/form/NamespaceSelector.js @@ -90,6 +90,17 @@ Ext.define('PBS.form.NamespaceSelector', { }, }); + if (me.excludeNs) { + me.store.addFilter( + new Ext.util.Filter({ + filterFn: (rec) => { + let ns = rec.data.ns; + return ns !== me.excludeNs && !ns.startsWith(`${me.excludeNs}/`); + }, + }), + ); + } + me.callParent(); }, }); diff --git a/www/window/NamespaceMove.js b/www/window/NamespaceMove.js new file mode 100644 index 00000000..2bbc0269 --- /dev/null +++ b/www/window/NamespaceMove.js @@ -0,0 +1,136 @@ +Ext.define('PBS.window.NamespaceMove', { + extend: 'Proxmox.window.Edit', + alias: 'widget.pbsNamespaceMove', + mixins: ['Proxmox.Mixin.CBind'], + + onlineHelp: 'storage-move-namespaces-groups', + + submitText: gettext('Move'), + isCreate: true, + showTaskViewer: true, + + cbind: { + url: '/api2/extjs/admin/datastore/{datastore}/move-namespace', + title: (get) => Ext.String.format(gettext("Move Namespace '{0}'"), get('namespace')), + }, + method: 'POST', + + width: 450, + fieldDefaults: { + labelWidth: 120, + }, + + cbindData: function (initialConfig) { + let ns = initialConfig.namespace ?? ''; + let parts = ns.split('/'); + let nsName = parts.pop(); + return { + nsName, + nsParent: parts.join('/'), + }; + }, + + // Compose the submitted target namespace from the current field values. + getTargetNs: function () { + let me = this; + let parent = me.down('[name=parent]').getValue() || ''; + let name = me.down('[name=name]').getValue(); + return parent ? `${parent}/${name}` : name; + }, + + // Returns the target-ns path that was submitted, for use by the caller after success. + getNewNamespace: function () { + return this.getTargetNs(); + }, + + items: { + xtype: 'inputpanel', + onGetValues: function (values) { + let win = this.up('window'); + let result = { + ns: win.namespace, + 'target-ns': win.getTargetNs(), + }; + if (values['delete-source'] !== undefined) { + result['delete-source'] = values['delete-source']; + } + if (values['merge-groups'] !== undefined) { + result['merge-groups'] = values['merge-groups']; + } + if (values['max-depth'] !== undefined && values['max-depth'] !== '') { + result['max-depth'] = values['max-depth']; + } + return result; + }, + items: [ + { + xtype: 'displayfield', + fieldLabel: gettext('Namespace'), + cbind: { + value: '{namespace}', + }, + }, + { + xtype: 'pbsNamespaceSelector', + name: 'parent', + fieldLabel: gettext('New Parent'), + allowBlank: true, + cbind: { + datastore: '{datastore}', + excludeNs: '{namespace}', + value: '{nsParent}', + }, + }, + { + xtype: 'proxmoxtextfield', + name: 'name', + fieldLabel: gettext('New Name'), + allowBlank: false, + maxLength: 31, + regex: PBS.Utils.SAFE_ID_RE, + regexText: gettext("Only alpha numerical, '_' and '-' (if not at start) allowed"), + cbind: { + value: '{nsName}', + }, + }, + ], + advancedItems: [ + { + xtype: 'pbsNamespaceMaxDepthReduced', + name: 'max-depth', + fieldLabel: gettext('Max Depth'), + emptyText: gettext('Unlimited'), + listeners: { + afterrender: function (field) { + let win = field.up('window'); + field.setLimit(win.namespace, null); + }, + }, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Limit how many levels of child namespaces to include. Leave empty to move the entire subtree.'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'merge-groups', + fieldLabel: gettext('Merge Groups'), + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Merge snapshots into existing groups with the same name in the target namespace. Requires matching ownership and non-overlapping snapshot times.'), + }, + }, + { + xtype: 'proxmoxcheckbox', + name: 'delete-source', + fieldLabel: gettext('Delete Source'), + checked: true, + autoEl: { + tag: 'div', + 'data-qtip': gettext('Remove the empty source namespace directories after moving all groups. Uncheck to keep the namespace structure.'), + }, + }, + ], + }, +}); -- 2.47.3