all lists on lists.proxmox.com
 help / color / mirror / Atom feed
* [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces
@ 2026-03-31 12:34 Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 1/8] ui: show empty groups Hannes Laimer
                   ` (8 more replies)
  0 siblings, 9 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add support for moving backup groups and entire namespace subtrees to
a different location within the same datastore.

Groups are moved with exclusive per-group and per-snapshot locking.
For S3, objects are copied to the target prefix before deleting the
source. Namespace moves process groups individually, deferring and
retrying lock conflicts once, so partially failed moves can be
completed with move_group.


v6, thanks @Fabian and @Dominik!:
 - drop ns locks, lock everything directly, like we do for delete. only
   difference, we dont do partial group moves, reason being that we
   cant move single snapshots, so cleanup would not really be possible
 - ui: disable purne for empty groups
 - ui: dont render verification status for empty groups

v5, thanks @Chris!:
 - lock dir instead of `.ns-lock` file
 - explicitly drop ns lock guards in specific order
 - improve cleanup of partially failed s3 moves, we now create the local
   empty dir+owner file before we start copying s3 objects, if any of
   the s3 ops fail, the dir stays behind and can be deleted through the
   UI(which also triggers a prefix cleanup on the s3 storage)
 - update parameters for `DataStore::lookup_datastore()`
 - ui: re-ordered actions, `move` now next to `verify`
 - ui: add move to right-click context menu
 - ui: show empty groups in the UI
 - add cli commands for both ns and group moves
 - add 2s ns lock timeout for worker tasks

*note*: given the UI change to show empty groups it could make sense to
not auto-delete a group if the last snapshot is deleted. For this series
though that is not relevant since we just need empty groups to be
deletable through the UI for partially failed s3 moves

Hannes Laimer (8):
  ui: show empty groups
  datastore: add move_group
  datastore: add move_namespace
  api: add PUT endpoint for move_group
  api: add PUT endpoint for move_namespace
  ui: add move group action
  ui: add move namespace action
  cli: add move-namespace and move-group commands

 pbs-datastore/src/backup_info.rs            | 153 ++++++++-
 pbs-datastore/src/datastore.rs              | 335 +++++++++++++++++++-
 src/api2/admin/datastore.rs                 |  78 ++++-
 src/api2/admin/namespace.rs                 |  78 ++++-
 src/bin/proxmox_backup_manager/datastore.rs |  84 ++++-
 www/Makefile                                |   2 +
 www/datastore/Content.js                    | 161 ++++++++--
 www/form/NamespaceSelector.js               |  11 +
 www/window/GroupMove.js                     |  56 ++++
 www/window/NamespaceMove.js                 |  79 +++++
 10 files changed, 1003 insertions(+), 34 deletions(-)
 create mode 100644 www/window/GroupMove.js
 create mode 100644 www/window/NamespaceMove.js

-- 
2.47.3





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

* [PATCH proxmox-backup v6 1/8] ui: show empty groups
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 2/8] datastore: add move_group Hannes Laimer
                   ` (7 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Display groups that have no snapshots. Currently, deleting the last
snapshot also removes the parent group, which causes metadata like
notes to be lost.

Showing empty groups is also needed for cleaning up partially failed
moves on an S3-backed datastore. Without them, the only way to delete
leftover groups (and their orphaned S3 objects) would be through the
API.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/datastore/Content.js | 89 +++++++++++++++++++++++++++-------------
 1 file changed, 61 insertions(+), 28 deletions(-)

diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index a2aa1949..dfb7787c 100644
--- a/www/datastore/Content.js
+++ b/www/datastore/Content.js
@@ -139,6 +139,22 @@ Ext.define('PBS.DataStoreContent', {
             });
         },
 
+        makeGroupEntry: function (btype, backupId) {
+            let cls = PBS.Utils.get_type_icon_cls(btype);
+            if (cls === '') {
+                return null;
+            }
+            return {
+                text: btype + '/' + backupId,
+                leaf: false,
+                iconCls: 'fa ' + cls,
+                expanded: false,
+                backup_type: btype,
+                backup_id: backupId,
+                children: [],
+            };
+        },
+
         getRecordGroups: function (records) {
             let groups = {};
 
@@ -150,27 +166,20 @@ Ext.define('PBS.DataStoreContent', {
                     continue;
                 }
 
-                let cls = PBS.Utils.get_type_icon_cls(btype);
-                if (cls === '') {
+                let entry = this.makeGroupEntry(btype, item.data['backup-id']);
+                if (entry === null) {
                     console.warn(`got unknown backup-type '${btype}'`);
                     continue; // FIXME: auto render? what do?
                 }
 
-                groups[group] = {
-                    text: group,
-                    leaf: false,
-                    iconCls: 'fa ' + cls,
-                    expanded: false,
-                    backup_type: item.data['backup-type'],
-                    backup_id: item.data['backup-id'],
-                    children: [],
-                };
+                groups[group] = entry;
             }
 
             return groups;
         },
 
-        updateGroupNotes: async function (view) {
+        loadGroups: async function () {
+            let view = this.getView();
             try {
                 let url = `/api2/extjs/admin/datastore/${view.datastore}/groups`;
                 if (view.namespace && view.namespace !== '') {
@@ -179,19 +188,24 @@ Ext.define('PBS.DataStoreContent', {
                 let {
                     result: { data: groups },
                 } = await Proxmox.Async.api2({ url });
-                let map = {};
-                for (const group of groups) {
-                    map[`${group['backup-type']}/${group['backup-id']}`] = group.comment;
-                }
-                view.getRootNode().cascade((node) => {
-                    if (node.data.ty === 'group') {
-                        let group = `${node.data.backup_type}/${node.data.backup_id}`;
-                        node.set('comment', map[group], { dirty: false });
-                    }
-                });
+                return groups;
             } catch (err) {
                 console.debug(err);
             }
+            return [];
+        },
+
+        updateGroupNotes: function (view, groupList) {
+            let map = {};
+            for (const group of groupList) {
+                map[`${group['backup-type']}/${group['backup-id']}`] = group.comment;
+            }
+            view.getRootNode().cascade((node) => {
+                if (node.data.ty === 'group') {
+                    let group = `${node.data.backup_type}/${node.data.backup_id}`;
+                    node.set('comment', map[group], { dirty: false });
+                }
+            });
         },
 
         loadNamespaceFromSameLevel: async function () {
@@ -215,7 +229,10 @@ Ext.define('PBS.DataStoreContent', {
             let me = this;
             let view = this.getView();
 
-            let namespaces = await me.loadNamespaceFromSameLevel();
+            let [namespaces, groupList] = await Promise.all([
+                me.loadNamespaceFromSameLevel(),
+                me.loadGroups(),
+            ]);
 
             if (!success) {
                 // TODO also check error code for != 403 ?
@@ -232,6 +249,22 @@ Ext.define('PBS.DataStoreContent', {
 
             let groups = this.getRecordGroups(records);
 
+            for (const item of groupList) {
+                let btype = item['backup-type'];
+                let group = btype + '/' + item['backup-id'];
+                if (groups[group] !== undefined) {
+                    continue;
+                }
+                let entry = me.makeGroupEntry(btype, item['backup-id']);
+                if (entry === null) {
+                    continue;
+                }
+                entry.leaf = true;
+                entry.comment = item.comment;
+                entry.owner = item.owner;
+                groups[group] = entry;
+            }
+
             let selected;
             let expanded = {};
 
@@ -399,7 +432,7 @@ Ext.define('PBS.DataStoreContent', {
                 );
             }
 
-            this.updateGroupNotes(view);
+            this.updateGroupNotes(view, groupList);
 
             if (selected !== undefined) {
                 let selection = view.getRootNode().findChildBy(
@@ -985,7 +1018,7 @@ Ext.define('PBS.DataStoreContent', {
             flex: 1,
             renderer: (v, meta, record) => {
                 let data = record.data;
-                if (!data || data.leaf || data.root) {
+                if (!data || (data.leaf && data.ty !== 'group') || data.root) {
                     return '';
                 }
 
@@ -1029,7 +1062,7 @@ Ext.define('PBS.DataStoreContent', {
                 },
                 dblclick: function (tree, el, row, col, ev, rec) {
                     let data = rec.data || {};
-                    if (data.leaf || data.root) {
+                    if ((data.leaf && data.ty !== 'group') || data.root) {
                         return;
                     }
                     let view = tree.up();
@@ -1065,7 +1098,7 @@ Ext.define('PBS.DataStoreContent', {
                     getTip: (v, m, rec) => Ext.String.format(gettext("Prune '{0}'"), v),
                     getClass: (v, m, { data }) =>
                         data.ty === 'group' ? 'fa fa-scissors' : 'pmx-hidden',
-                    isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group',
+                    isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group' || !!data.leaf,
                 },
                 {
                     handler: 'onProtectionChange',
@@ -1230,7 +1263,7 @@ Ext.define('PBS.DataStoreContent', {
                     return ''; // TODO: accumulate verify of all groups into root NS node?
                 }
                 let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
-                if (v === undefined || v === null) {
+                if (v === undefined || v === null || record.data.count === 0) {
                     return record.data.leaf ? '' : i('question-circle-o warning', gettext('None'));
                 }
                 let tip, iconCls, txt;
-- 
2.47.3





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

* [PATCH proxmox-backup v6 2/8] datastore: add move_group
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 1/8] ui: show empty groups Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 3/8] datastore: add move_namespace Hannes Laimer
                   ` (6 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add support for moving a single backup group to a different namespace
within the same datastore.

For the filesystem backend, the group directory is relocated with a
single rename(2). For S3, all objects under the source prefix are
copied to the target prefix and then deleted.

An exclusive group lock and exclusive locks on every snapshot are
acquired before the move, mirroring the locking strategy used when
destroying a group. This ensures no concurrent readers, writers, or
verify tasks are operating on any snapshot being moved. Existence
checks are performed under those locks to avoid TOCTOU races.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pbs-datastore/src/backup_info.rs | 148 ++++++++++++++++++++++++++++++-
 pbs-datastore/src/datastore.rs   |  56 ++++++++++++
 2 files changed, 203 insertions(+), 1 deletion(-)

diff --git a/pbs-datastore/src/backup_info.rs b/pbs-datastore/src/backup_info.rs
index c33eb307..a8a56198 100644
--- a/pbs-datastore/src/backup_info.rs
+++ b/pbs-datastore/src/backup_info.rs
@@ -9,7 +9,7 @@ use std::time::Duration;
 use anyhow::{bail, format_err, Context, Error};
 use const_format::concatcp;
 
-use proxmox_s3_client::S3PathPrefix;
+use proxmox_s3_client::{S3ObjectKey, S3PathPrefix};
 use proxmox_sys::fs::{lock_dir_noblock, lock_dir_noblock_shared, replace_file, CreateOptions};
 use proxmox_systemd::escape_unit;
 
@@ -273,6 +273,152 @@ impl BackupGroup {
         Ok(delete_stats)
     }
 
+    /// Move this group to a new namespace.
+    ///
+    /// For the filesystem backend, uses `rename` to atomically relocate the group directory. For
+    /// the S3 backend, copies all objects to the destination prefix first, then renames the local
+    /// cache directory, then deletes the source objects. A copy failure returns an error with the
+    /// group intact at source. A delete failure is logged as a warning - any un-deleted source
+    /// objects are orphaned and must be removed manually.
+    ///
+    /// The caller must have created the target type directory
+    /// (e.g. `{target_ns}/{backup_type}/`) before calling this method.
+    ///
+    /// The caller must hold an exclusive group lock and exclusive locks on all snapshots in the
+    /// group. This prevents concurrent readers, writers, and verify tasks from operating on the
+    /// group and ensures no new objects are added between the S3 copy sweep and the subsequent
+    /// deletes.
+    pub(crate) fn move_to(
+        &self,
+        target_ns: &BackupNamespace,
+        backend: &DatastoreBackend,
+    ) -> Result<(), Error> {
+        let src_path = self.full_group_path();
+        let target_path = self.store.group_path(target_ns, &self.group);
+
+        log::info!("moving backup group {src_path:?} to {target_path:?}");
+
+        match backend {
+            DatastoreBackend::Filesystem => {
+                std::fs::rename(&src_path, &target_path).with_context(|| {
+                    format!("failed to move group {src_path:?} to {target_path:?}")
+                })?;
+                // Remove the now-stale source lock file. The caller's lock guard
+                // still holds the flock via the open FD until it is dropped.
+                let _ = std::fs::remove_file(self.lock_path());
+            }
+            DatastoreBackend::S3(s3_client) => {
+                // Build S3 key prefixes for source and target groups, e.g.:
+                //   src: ".cnt/a/b/vm/100/"
+                //   tgt: ".cnt/a/c/vm/100/"
+                let src_rel = self.relative_group_path();
+                let src_rel_str = src_rel
+                    .to_str()
+                    .ok_or_else(|| format_err!("invalid source group path"))?;
+                let src_prefix_str = format!("{S3_CONTENT_PREFIX}/{src_rel_str}/");
+
+                let mut tgt_rel = target_ns.path();
+                tgt_rel.push(self.group.ty.as_str());
+                tgt_rel.push(&self.group.id);
+                let tgt_rel_str = tgt_rel
+                    .to_str()
+                    .ok_or_else(|| format_err!("invalid target group path"))?;
+                let tgt_prefix_str = format!("{S3_CONTENT_PREFIX}/{tgt_rel_str}/");
+
+                // S3 list_objects returns full keys with the store name as the leading component,
+                // e.g. "mystore/.cnt/a/b/vm/100/2026-01-01T00:00:00Z/drive-scsi0.img.fidx".
+                // Strip "mystore/" to get the relative key used by copy_object.
+                let store_prefix = format!("{}/", self.store.name());
+
+                log::debug!(
+                    "S3 move: listing prefix '{src_prefix_str}', store_prefix='{store_prefix}', tgt_prefix='{tgt_prefix_str}'"
+                );
+
+                // Copy all objects to the target prefix first. No source objects are deleted
+                // until all copies succeed, so a copy failure leaves the group intact at source.
+                let prefix = S3PathPrefix::Some(src_prefix_str.clone());
+                let mut token: Option<String> = None;
+                let mut src_keys = Vec::new();
+
+                loop {
+                    let result = proxmox_async::runtime::block_on(
+                        s3_client.list_objects_v2(&prefix, token.as_deref()),
+                    )
+                    .context("failed to list group objects on S3 backend")?;
+
+                    log::debug!(
+                        "S3 move: listed {} objects (truncated={})",
+                        result.contents.len(),
+                        result.is_truncated
+                    );
+
+                    for item in result.contents {
+                        let full_key_str: &str = &item.key;
+                        log::debug!("S3 move: processing key '{full_key_str}'");
+                        let rel_key =
+                            full_key_str.strip_prefix(&store_prefix).ok_or_else(|| {
+                                format_err!("unexpected key prefix in '{full_key_str}'")
+                            })?;
+                        let src_key = S3ObjectKey::try_from(rel_key)?;
+
+                        // Replace the source group prefix with the target prefix,
+                        // keeping the snapshot dir and filename (suffix) intact.
+                        let suffix = rel_key
+                            .strip_prefix(&src_prefix_str)
+                            .ok_or_else(|| format_err!("unexpected key format '{rel_key}'"))?;
+                        let dst_key_str = format!("{tgt_prefix_str}{suffix}");
+                        let dst_key = S3ObjectKey::try_from(dst_key_str.as_str())?;
+
+                        log::debug!("S3 move: copy '{rel_key}' -> '{dst_key_str}'");
+                        proxmox_async::runtime::block_on(
+                            s3_client.copy_object(src_key.clone(), dst_key),
+                        )
+                        .with_context(|| format!("failed to copy S3 object '{rel_key}'"))?;
+                        src_keys.push(src_key);
+                    }
+
+                    if result.is_truncated {
+                        token = result.next_continuation_token;
+                    } else {
+                        break;
+                    }
+                }
+
+                // All copies succeeded. Remove any pre-created target directory (the
+                // caller may have created one) before renaming - rename(2) requires
+                // the target to be empty if it exists.
+                if target_path.exists() {
+                    let _ = std::fs::remove_dir_all(&target_path);
+                }
+                // Rename the local cache directory before deleting source objects so that
+                // the local cache reflects the target state as soon as possible.
+                std::fs::rename(&src_path, &target_path).with_context(|| {
+                    format!("failed to move group {src_path:?} to {target_path:?}")
+                })?;
+                let _ = std::fs::remove_file(self.lock_path());
+
+                // Delete source objects. In case of a delete failure the group is already at the
+                // target (S3 copies + local cache). Treat delete failures as warnings so the
+                // caller does not misreport the group as "failed to move".
+                // Un-deleted sources must be removed manually.
+                log::debug!("S3 move: deleting {} source objects", src_keys.len());
+                for src_key in src_keys {
+                    log::debug!("S3 move: delete '{src_key:?}'");
+                    if let Err(err) =
+                        proxmox_async::runtime::block_on(s3_client.delete_object(src_key.clone()))
+                    {
+                        log::warn!(
+                            "S3 move: failed to delete source object '{src_key:?}' \
+                            (group already at target, orphaned object requires manual removal): {err:#}"
+                        );
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+
     /// Helper function, assumes that no more snapshots are present in the group.
     fn remove_group_dir(&self) -> Result<(), Error> {
         let note_path = self.store.group_notes_path(&self.ns, &self.group);
diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs
index ef378c69..d74e7e42 100644
--- a/pbs-datastore/src/datastore.rs
+++ b/pbs-datastore/src/datastore.rs
@@ -1014,6 +1014,62 @@ impl DataStore {
         backup_group.destroy(&self.backend()?)
     }
 
+    /// Move a single backup group to a different namespace within the same datastore.
+    ///
+    /// Acquires an exclusive group lock and exclusive locks on every snapshot in the group to
+    /// ensure no concurrent readers, writers, or verify tasks are operating on any snapshot.
+    /// This mirrors the locking strategy of `BackupGroup::destroy()`.
+    pub fn move_group(
+        self: &Arc<Self>,
+        source_ns: &BackupNamespace,
+        group: &pbs_api_types::BackupGroup,
+        target_ns: &BackupNamespace,
+    ) -> Result<(), Error> {
+        if source_ns == target_ns {
+            bail!("source and target namespace must be different");
+        }
+
+        let source_group = self.backup_group(source_ns.clone(), group.clone());
+        let target_group = self.backup_group(target_ns.clone(), group.clone());
+
+        // Acquire exclusive group lock - prevents new snapshot additions/removals.
+        let _group_lock = source_group
+            .lock()
+            .with_context(|| format!("failed to lock group '{group}' for move"))?;
+
+        // Acquire exclusive lock on each snapshot - ensures no concurrent readers, verify
+        // tasks, or other operations are active on any snapshot in this group.
+        let mut _snap_locks = Vec::new();
+        for snap in source_group.iter_snapshots()? {
+            let snap = snap?;
+            _snap_locks.push(snap.lock().with_context(|| {
+                format!(
+                    "cannot move group '{group}': snapshot '{}' is in use",
+                    snap.dir(),
+                )
+            })?);
+        }
+
+        // Check existence under locks to avoid TOCTOU races.
+        if !self.namespace_exists(target_ns) {
+            bail!("target namespace '{target_ns}' does not exist");
+        }
+        if !source_group.exists() {
+            bail!("group '{group}' does not exist in namespace '{source_ns}'");
+        }
+        if target_group.exists() {
+            bail!("group '{group}' already exists in target namespace '{target_ns}'");
+        }
+
+        let backend = self.backend()?;
+
+        std::fs::create_dir_all(self.type_path(target_ns, group.ty)).with_context(|| {
+            format!("failed to create type directory in '{target_ns}' for move")
+        })?;
+
+        source_group.move_to(target_ns, &backend)
+    }
+
     /// Remove a backup directory including all content
     pub fn remove_backup_dir(
         self: &Arc<Self>,
-- 
2.47.3





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

* [PATCH proxmox-backup v6 3/8] datastore: add move_namespace
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 1/8] ui: show empty groups Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 2/8] datastore: add move_group Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 4/8] api: add PUT endpoint for move_group Hannes Laimer
                   ` (5 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Relocate an entire namespace subtree (the given namespace, all child
namespaces, and their groups) to a new location within the same
datastore.

Groups are moved one at a time with per-group and per-snapshot
exclusive locking to ensure no concurrent readers or writers are
active on any snapshot being moved. Groups that cannot be locked
(because a backup, verify, or other task is running) are deferred and
retried once after all other groups have been processed - the
conflicting task may have finished by then.

Groups that still cannot be moved after the retry are reported in the
task log and remain at the source so they can be retried with
move_group individually. Source namespaces where all groups succeeded
have their local directories (and S3 markers, if applicable) removed
deepest-first.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pbs-datastore/src/backup_info.rs |   7 +-
 pbs-datastore/src/datastore.rs   | 279 ++++++++++++++++++++++++++++++-
 2 files changed, 284 insertions(+), 2 deletions(-)

diff --git a/pbs-datastore/src/backup_info.rs b/pbs-datastore/src/backup_info.rs
index a8a56198..5e3f9202 100644
--- a/pbs-datastore/src/backup_info.rs
+++ b/pbs-datastore/src/backup_info.rs
@@ -296,7 +296,12 @@ impl BackupGroup {
         let src_path = self.full_group_path();
         let target_path = self.store.group_path(target_ns, &self.group);
 
-        log::info!("moving backup group {src_path:?} to {target_path:?}");
+        log::info!(
+            "moving group '{}/{}' from '{}' to '{target_ns}'",
+            self.group.ty,
+            self.group.id,
+            self.ns,
+        );
 
         match backend {
             DatastoreBackend::Filesystem => {
diff --git a/pbs-datastore/src/datastore.rs b/pbs-datastore/src/datastore.rs
index d74e7e42..f49644b2 100644
--- a/pbs-datastore/src/datastore.rs
+++ b/pbs-datastore/src/datastore.rs
@@ -31,7 +31,7 @@ use pbs_api_types::{
     ArchiveType, Authid, BackupGroupDeleteStats, BackupNamespace, BackupType, ChunkOrder,
     DataStoreConfig, DatastoreBackendConfig, DatastoreBackendType, DatastoreFSyncLevel,
     DatastoreTuning, GarbageCollectionCacheStats, GarbageCollectionStatus, MaintenanceMode,
-    MaintenanceType, Operation, UPID,
+    MaintenanceType, Operation, MAX_NAMESPACE_DEPTH, UPID,
 };
 use pbs_config::s3::S3_CFG_TYPE_ID;
 use pbs_config::{BackupLockGuard, ConfigVersionCache};
@@ -84,6 +84,29 @@ const S3_DELETE_BATCH_LIMIT: usize = 100;
 // max defer time for s3 batch deletions
 const S3_DELETE_DEFER_LIMIT_SECONDS: Duration = Duration::from_secs(60 * 5);
 
+/// Error from a per-group move attempt inside `move_namespace`, distinguishing lock conflicts
+/// (retryable - the conflicting task may finish) from hard errors (not retryable).
+struct MoveGroupError {
+    source: Error,
+    is_lock_conflict: bool,
+}
+
+impl MoveGroupError {
+    fn lock(source: Error) -> Self {
+        Self {
+            source,
+            is_lock_conflict: true,
+        }
+    }
+
+    fn hard(source: Error) -> Self {
+        Self {
+            source,
+            is_lock_conflict: false,
+        }
+    }
+}
+
 /// checks if auth_id is owner, or, if owner is a token, if
 /// auth_id is the user of the token
 pub fn check_backup_owner(owner: &Authid, auth_id: &Authid) -> Result<(), Error> {
@@ -1000,6 +1023,203 @@ impl DataStore {
         Ok((removed_all_requested, stats))
     }
 
+    /// Move a backup namespace (including all child namespaces and groups) to a new location.
+    ///
+    /// Groups are moved one at a time with per-group and per-snapshot exclusive locking to
+    /// ensure no concurrent readers or writers are active on any snapshot being moved. Groups
+    /// that cannot be locked (because a backup, verify, or other task is running) are skipped
+    /// and reported in the error. The same iterative semantics apply to both the filesystem
+    /// and S3 backends.
+    ///
+    /// Fails if:
+    /// - `source_ns` is the root namespace
+    /// - `source_ns` == `target_ns`
+    /// - `source_ns` does not exist
+    /// - `target_ns` already exists (to prevent silent merging)
+    /// - `target_ns`'s parent does not exist
+    /// - `source_ns` is an ancestor of `target_ns`
+    /// - the move would exceed the maximum namespace depth
+    pub fn move_namespace(
+        self: &Arc<Self>,
+        source_ns: &BackupNamespace,
+        target_ns: &BackupNamespace,
+    ) -> Result<(), Error> {
+        if source_ns.is_root() {
+            bail!("cannot move root namespace");
+        }
+        if source_ns == target_ns {
+            bail!("source and target namespace must be different");
+        }
+
+        if !self.namespace_exists(source_ns) {
+            bail!("source namespace '{source_ns}' does not exist");
+        }
+        if self.namespace_exists(target_ns) {
+            bail!("target namespace '{target_ns}' already exists");
+        }
+        let target_parent = target_ns.parent();
+        if !self.namespace_exists(&target_parent) {
+            bail!("target namespace parent '{target_parent}' does not exist");
+        }
+        if source_ns.contains(target_ns).is_some() {
+            bail!(
+                "cannot move namespace '{source_ns}' into its own subtree (target: '{target_ns}')"
+            );
+        }
+
+        let all_source_ns: Vec<BackupNamespace> = self
+            .recursive_iter_backup_ns(source_ns.clone())?
+            .collect::<Result<Vec<_>, Error>>()?;
+
+        let all_source_groups: Vec<BackupGroup> = all_source_ns
+            .iter()
+            .map(|ns| self.iter_backup_groups(ns.clone()))
+            .collect::<Result<Vec<_>, Error>>()?
+            .into_iter()
+            .flatten()
+            .collect::<Result<Vec<_>, Error>>()?;
+
+        let subtree_depth = all_source_ns
+            .iter()
+            .map(BackupNamespace::depth)
+            .max()
+            .map_or(0, |d| d - source_ns.depth());
+        if subtree_depth + target_ns.depth() > MAX_NAMESPACE_DEPTH {
+            bail!(
+                "move would exceed maximum namespace depth \
+                ({subtree_depth}+{} > {MAX_NAMESPACE_DEPTH})",
+                target_ns.depth(),
+            );
+        }
+
+        let backend = self.backend()?;
+
+        log::info!(
+            "moving namespace '{source_ns}' -> '{target_ns}': {} namespaces, {} groups",
+            all_source_ns.len(),
+            all_source_groups.len(),
+        );
+
+        // Create target local namespace directories upfront (covers empty namespaces).
+        for ns in &all_source_ns {
+            let target_child = ns.map_prefix(source_ns, target_ns)?;
+            std::fs::create_dir_all(self.namespace_path(&target_child)).with_context(|| {
+                format!("failed to create local dir for target namespace '{target_child}'")
+            })?;
+        }
+
+        // For S3: create namespace markers for all target namespaces.
+        if let DatastoreBackend::S3(s3_client) = &backend {
+            for ns in &all_source_ns {
+                let target_child = ns.map_prefix(source_ns, target_ns)?;
+                let object_key = crate::s3::object_key_from_path(
+                    &target_child.path(),
+                    NAMESPACE_MARKER_FILENAME,
+                )
+                .context("invalid namespace marker object key")?;
+                log::debug!("creating S3 namespace marker for '{target_child}': {object_key:?}");
+                proxmox_async::runtime::block_on(
+                    s3_client.upload_no_replace_with_retry(object_key, Bytes::from("")),
+                )
+                .context("failed to create namespace marker on S3 backend")?;
+            }
+        }
+
+        // Move each group with per-group and per-snapshot locking. Groups that cannot be
+        // locked (concurrent operation in progress) are deferred and retried once after all
+        // other groups have been processed - the conflicting task may have finished by then.
+        let mut failed_groups: Vec<(BackupNamespace, String)> = Vec::new();
+        let mut failed_ns: HashSet<BackupNamespace> = HashSet::new();
+        let mut deferred: Vec<&BackupGroup> = Vec::new();
+
+        for group in &all_source_groups {
+            if let Err(err) = self.lock_and_move_group(group, source_ns, target_ns, &backend) {
+                if err.is_lock_conflict {
+                    log::info!(
+                        "deferring group '{}' in '{}' - lock conflict, will retry: {:#}",
+                        group.group(),
+                        group.backup_ns(),
+                        err.source,
+                    );
+                    deferred.push(group);
+                } else {
+                    warn!(
+                        "failed to move group '{}' in '{}': {:#}",
+                        group.group(),
+                        group.backup_ns(),
+                        err.source,
+                    );
+                    failed_groups.push((group.backup_ns().clone(), group.group().to_string()));
+                    failed_ns.insert(group.backup_ns().clone());
+                }
+            }
+        }
+
+        // Retry deferred groups once - conflicting tasks may have finished.
+        if !deferred.is_empty() {
+            log::info!("retrying {} deferred group(s)...", deferred.len());
+            for group in deferred {
+                if let Err(err) = self.lock_and_move_group(group, source_ns, target_ns, &backend) {
+                    warn!(
+                        "failed to move group '{}' in '{}' on retry: {:#}",
+                        group.group(),
+                        group.backup_ns(),
+                        err.source,
+                    );
+                    failed_groups.push((group.backup_ns().clone(), group.group().to_string()));
+                    failed_ns.insert(group.backup_ns().clone());
+                }
+            }
+        }
+
+        // Clean up source namespaces that are now fully empty (all groups moved).
+        // Process deepest-first so parent directories are already empty when reached.
+        for ns in all_source_ns.iter().rev() {
+            // Skip if this namespace itself or any descendant still has groups.
+            let has_remaining = failed_ns
+                .iter()
+                .any(|fns| fns == ns || ns.contains(fns).is_some());
+            if has_remaining {
+                continue;
+            }
+
+            // For S3: delete the source namespace marker.
+            if let DatastoreBackend::S3(s3_client) = &backend {
+                let object_key =
+                    crate::s3::object_key_from_path(&ns.path(), NAMESPACE_MARKER_FILENAME)
+                        .context("invalid namespace marker object key")?;
+                log::debug!("deleting source S3 namespace marker for '{ns}': {object_key:?}");
+                proxmox_async::runtime::block_on(s3_client.delete_object(object_key))
+                    .context("failed to delete source namespace marker on S3 backend")?;
+            }
+
+            // Remove the source local directory. Try type subdirectories first
+            // (they should be empty after the per-group renames), then the namespace dir.
+            let ns_path = self.namespace_path(ns);
+            if let Ok(entries) = std::fs::read_dir(&ns_path) {
+                for entry in entries.flatten() {
+                    let _ = std::fs::remove_dir(entry.path());
+                }
+            }
+            let _ = std::fs::remove_dir(&ns_path);
+        }
+
+        if !failed_groups.is_empty() {
+            let group_list: Vec<String> = failed_groups
+                .iter()
+                .map(|(ns, group)| format!("'{group}' in '{ns}'"))
+                .collect();
+            bail!(
+                "namespace move partially completed; {} group(s) could not be moved \
+                and remain at source: {}",
+                failed_groups.len(),
+                group_list.join(", "),
+            );
+        }
+
+        Ok(())
+    }
+
     /// Remove a complete backup group including all snapshots.
     ///
     /// Returns `BackupGroupDeleteStats`, containing the number of deleted snapshots
@@ -1070,6 +1290,63 @@ impl DataStore {
         source_group.move_to(target_ns, &backend)
     }
 
+    /// Try to lock and move a single group as part of a namespace move. Acquires exclusive
+    /// locks on the group and all its snapshots, then performs the actual move. Returns a
+    /// `MoveGroupError` on failure, indicating whether the failure was a lock conflict
+    /// (retryable) or a hard error.
+    fn lock_and_move_group(
+        self: &Arc<Self>,
+        group: &BackupGroup,
+        source_ns: &BackupNamespace,
+        target_ns: &BackupNamespace,
+        backend: &DatastoreBackend,
+    ) -> Result<(), MoveGroupError> {
+        let target_group_ns = group
+            .backup_ns()
+            .map_prefix(source_ns, target_ns)
+            .map_err(MoveGroupError::hard)?;
+
+        // Acquire exclusive group lock - prevents new snapshot additions/removals.
+        let _group_lock = group.lock().map_err(MoveGroupError::lock)?;
+
+        // Acquire exclusive lock on each snapshot to ensure no concurrent readers.
+        let mut _snap_locks = Vec::new();
+        for snap in group.iter_snapshots().map_err(MoveGroupError::hard)? {
+            let snap = snap.map_err(MoveGroupError::hard)?;
+            _snap_locks.push(snap.lock().map_err(MoveGroupError::lock)?);
+        }
+
+        // Ensure the target type directory exists before move_to renames into it.
+        std::fs::create_dir_all(self.type_path(&target_group_ns, group.group().ty))
+            .map_err(|err| MoveGroupError::hard(err.into()))?;
+
+        // For S3: pre-create the target group directory with the source owner so the
+        // group is visible in the UI during the S3 copy. On success move_to removes
+        // this directory and renames the source into its place. On failure it stays so
+        // users can delete the group via the API (the owner file enables the auth
+        // check), which also cleans up orphaned S3 objects at that prefix.
+        if matches!(backend, DatastoreBackend::S3(_)) {
+            let target_group_path = self.group_path(&target_group_ns, group.group());
+            std::fs::create_dir_all(&target_group_path)
+                .map_err(|err| MoveGroupError::hard(err.into()))?;
+            if let Ok(owner) = group.get_owner() {
+                let target_group =
+                    self.backup_group(target_group_ns.clone(), group.group().clone());
+                if let Err(err) = target_group.set_owner(&owner, true) {
+                    warn!(
+                        "move_namespace: failed to set owner for target group '{}' in '{}': {err:#}",
+                        group.group(),
+                        target_group_ns,
+                    );
+                }
+            }
+        }
+
+        group
+            .move_to(&target_group_ns, backend)
+            .map_err(MoveGroupError::hard)
+    }
+
     /// Remove a backup directory including all content
     pub fn remove_backup_dir(
         self: &Arc<Self>,
-- 
2.47.3





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

* [PATCH proxmox-backup v6 4/8] api: add PUT endpoint for move_group
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (2 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 3/8] datastore: add move_namespace Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 5/8] api: add PUT endpoint for move_namespace Hannes Laimer
                   ` (4 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add a PUT handler on /admin/datastore/{store}/groups to move a single
backup group to a different namespace within the same datastore. The
handler performs fast pre-checks synchronously and spawns a worker
task for the actual move.

Requires DATASTORE_MODIFY on both the source and target namespaces.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/api2/admin/datastore.rs | 78 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 77 insertions(+), 1 deletion(-)

diff --git a/src/api2/admin/datastore.rs b/src/api2/admin/datastore.rs
index cca34055..68b1bbfc 100644
--- a/src/api2/admin/datastore.rs
+++ b/src/api2/admin/datastore.rs
@@ -69,7 +69,9 @@ use proxmox_rest_server::{formatter, worker_is_active, WorkerTask};
 
 use crate::api2::backup::optional_ns_param;
 use crate::api2::node::rrd::create_value_from_rrd;
-use crate::backup::{check_ns_privs_full, ListAccessibleBackupGroups, VerifyWorker, NS_PRIVS_OK};
+use crate::backup::{
+    check_ns_privs, check_ns_privs_full, ListAccessibleBackupGroups, VerifyWorker, NS_PRIVS_OK,
+};
 use crate::server::jobstate::{compute_schedule_status, Job, JobState};
 use crate::tools::{backup_info_to_snapshot_list_item, get_all_snapshot_files, read_backup_index};
 
@@ -278,6 +280,79 @@ pub async fn delete_group(
     .await?
 }
 
+#[api(
+    input: {
+        properties: {
+            store: { schema: DATASTORE_SCHEMA },
+            ns: {
+                type: BackupNamespace,
+                optional: true,
+            },
+            group: {
+                type: pbs_api_types::BackupGroup,
+                flatten: true,
+            },
+            "new-ns": {
+                type: BackupNamespace,
+                optional: true,
+            },
+        },
+    },
+    returns: {
+        schema: UPID_SCHEMA,
+    },
+    access: {
+        permission: &Permission::Anybody,
+        description: "Requires DATASTORE_MODIFY on both the source and target namespace.",
+    },
+)]
+/// Move a backup group to a different namespace within the same datastore.
+pub fn move_group(
+    store: String,
+    ns: Option<BackupNamespace>,
+    group: pbs_api_types::BackupGroup,
+    new_ns: Option<BackupNamespace>,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+    let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+    let ns = ns.unwrap_or_default();
+    let new_ns = new_ns.unwrap_or_default();
+
+    check_ns_privs(&store, &ns, &auth_id, PRIV_DATASTORE_MODIFY)?;
+    check_ns_privs(&store, &new_ns, &auth_id, PRIV_DATASTORE_MODIFY)?;
+
+    let datastore = DataStore::lookup_datastore(&store, Operation::Write)?;
+
+    // Best-effort pre-checks for a fast synchronous error before spawning a worker.
+    if ns == new_ns {
+        bail!("source and target namespace must be different");
+    }
+    if !datastore.namespace_exists(&new_ns) {
+        bail!("target namespace '{new_ns}' does not exist");
+    }
+    let source_group = datastore.backup_group(ns.clone(), group.clone());
+    if !source_group.exists() {
+        bail!("group '{group}' does not exist in namespace '{ns}'");
+    }
+    let target_group = datastore.backup_group(new_ns.clone(), group.clone());
+    if target_group.exists() {
+        bail!("group '{group}' already exists in target namespace '{new_ns}'");
+    }
+
+    let worker_id = format!("{store}:{ns}:{group}");
+    let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+    let upid_str = WorkerTask::new_thread(
+        "move-group",
+        Some(worker_id),
+        auth_id.to_string(),
+        to_stdout,
+        move |_worker| datastore.move_group(&ns, &group, &new_ns),
+    )?;
+
+    Ok(json!(upid_str))
+}
+
 #[api(
     input: {
         properties: {
@@ -2828,6 +2903,7 @@ const DATASTORE_INFO_SUBDIRS: SubdirMap = &[
         "groups",
         &Router::new()
             .get(&API_METHOD_LIST_GROUPS)
+            .put(&API_METHOD_MOVE_GROUP)
             .delete(&API_METHOD_DELETE_GROUP),
     ),
     ("mount", &Router::new().post(&API_METHOD_MOUNT)),
-- 
2.47.3





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

* [PATCH proxmox-backup v6 5/8] api: add PUT endpoint for move_namespace
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (3 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 4/8] api: add PUT endpoint for move_group Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 6/8] ui: add move group action Hannes Laimer
                   ` (3 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add a PUT handler on /admin/datastore/{store}/namespace to move a
namespace (including all child namespaces and groups) to a new
location within the same datastore. The handler performs fast
pre-checks synchronously and spawns a worker task for the actual move.

Requires DATASTORE_MODIFY on the parent of both the source and target
namespaces, matching the permissions used by create_namespace() and
delete_namespace().

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/api2/admin/namespace.rs | 78 ++++++++++++++++++++++++++++++++++++-
 1 file changed, 76 insertions(+), 2 deletions(-)

diff --git a/src/api2/admin/namespace.rs b/src/api2/admin/namespace.rs
index 30e24d8d..5b64fb0d 100644
--- a/src/api2/admin/namespace.rs
+++ b/src/api2/admin/namespace.rs
@@ -1,12 +1,16 @@
 use anyhow::{bail, Error};
 
 use pbs_config::CachedUserInfo;
-use proxmox_router::{http_bail, ApiMethod, Permission, Router, RpcEnvironment};
+use proxmox_rest_server::WorkerTask;
+use proxmox_router::{
+    http_bail, ApiMethod, Permission, Router, RpcEnvironment, RpcEnvironmentType,
+};
 use proxmox_schema::*;
+use serde_json::{json, Value};
 
 use pbs_api_types::{
     Authid, BackupGroupDeleteStats, BackupNamespace, NamespaceListItem, Operation,
-    DATASTORE_SCHEMA, NS_MAX_DEPTH_SCHEMA, PROXMOX_SAFE_ID_FORMAT,
+    DATASTORE_SCHEMA, NS_MAX_DEPTH_SCHEMA, PROXMOX_SAFE_ID_FORMAT, UPID_SCHEMA,
 };
 
 use pbs_datastore::DataStore;
@@ -189,7 +193,77 @@ pub fn delete_namespace(
     Ok(stats)
 }
 
+#[api(
+    input: {
+        properties: {
+            store: { schema: DATASTORE_SCHEMA },
+            ns: {
+                type: BackupNamespace,
+            },
+            "new-ns": {
+                type: BackupNamespace,
+            },
+        },
+    },
+    returns: {
+        schema: UPID_SCHEMA,
+    },
+    access: {
+        permission: &Permission::Anybody,
+        description: "Requires DATASTORE_MODIFY on the parent of 'ns' and on the parent of 'new-ns'.",
+    },
+)]
+/// Move a backup namespace (including all child namespaces and groups) to a new location.
+pub fn move_namespace(
+    store: String,
+    ns: BackupNamespace,
+    new_ns: BackupNamespace,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Value, Error> {
+    let auth_id: Authid = rpcenv.get_auth_id().unwrap().parse()?;
+
+    check_ns_modification_privs(&store, &ns, &auth_id)?;
+    check_ns_modification_privs(&store, &new_ns, &auth_id)?;
+
+    let datastore = DataStore::lookup_datastore(&store, Operation::Write)?;
+
+    // Best-effort pre-checks for a fast synchronous error before spawning a worker.
+    if ns.is_root() {
+        bail!("cannot move root namespace");
+    }
+    if ns == new_ns {
+        bail!("source and target namespace must be different");
+    }
+    if !datastore.namespace_exists(&ns) {
+        bail!("source namespace '{ns}' does not exist");
+    }
+    if datastore.namespace_exists(&new_ns) {
+        bail!("target namespace '{new_ns}' already exists");
+    }
+    let target_parent = new_ns.parent();
+    if !datastore.namespace_exists(&target_parent) {
+        bail!("target parent namespace '{target_parent}' does not exist");
+    }
+    if ns.contains(&new_ns).is_some() {
+        bail!("cannot move namespace '{ns}' into its own subtree (target: '{new_ns}')");
+    }
+
+    let worker_id = format!("{store}:{ns}");
+    let to_stdout = rpcenv.env_type() == RpcEnvironmentType::CLI;
+
+    let upid_str = WorkerTask::new_thread(
+        "move-namespace",
+        Some(worker_id),
+        auth_id.to_string(),
+        to_stdout,
+        move |_worker| datastore.move_namespace(&ns, &new_ns),
+    )?;
+
+    Ok(json!(upid_str))
+}
+
 pub const ROUTER: Router = Router::new()
     .get(&API_METHOD_LIST_NAMESPACES)
     .post(&API_METHOD_CREATE_NAMESPACE)
+    .put(&API_METHOD_MOVE_NAMESPACE)
     .delete(&API_METHOD_DELETE_NAMESPACE);
-- 
2.47.3





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

* [PATCH proxmox-backup v6 6/8] ui: add move group action
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (4 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 5/8] api: add PUT endpoint for move_namespace Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 7/8] ui: add move namespace action Hannes Laimer
                   ` (2 subsequent siblings)
  8 siblings, 0 replies; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add a "Move" action to the backup group context menu and action
column. Opens a dialog where the user selects a target namespace,
then submits a PUT to the move_group API endpoint.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/Makefile             |  1 +
 www/datastore/Content.js | 47 +++++++++++++++++++++++++++++++++
 www/window/GroupMove.js  | 56 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 104 insertions(+)
 create mode 100644 www/window/GroupMove.js

diff --git a/www/Makefile b/www/Makefile
index 9ebf0445..7745d9f4 100644
--- a/www/Makefile
+++ b/www/Makefile
@@ -76,6 +76,7 @@ JSSRC=							\
 	config/PruneAndGC.js				\
 	window/ACLEdit.js				\
 	window/BackupGroupChangeOwner.js		\
+	window/GroupMove.js				\
 	window/CreateDirectory.js			\
 	window/DataStoreEdit.js				\
 	window/NamespaceEdit.js				\
diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index dfb7787c..585a1a2d 100644
--- a/www/datastore/Content.js
+++ b/www/datastore/Content.js
@@ -668,6 +668,27 @@ Ext.define('PBS.DataStoreContent', {
             });
         },
 
+        moveGroup: function (data) {
+            let me = this;
+            let view = me.getView();
+            let group = `${data.backup_type}/${data.backup_id}`;
+            Ext.create('PBS.window.GroupMove', {
+                datastore: view.datastore,
+                namespace: view.namespace,
+                backupType: data.backup_type,
+                backupId: data.backup_id,
+                group,
+                taskDone: () => me.reload(),
+            }).show();
+        },
+
+        onMove: function (table, rI, cI, item, e, { data }) {
+            let me = this;
+            if (data.ty === 'group') {
+                me.moveGroup(data);
+            }
+        },
+
         forgetGroup: function (data) {
             let me = this;
             let view = me.getView();
@@ -972,6 +993,7 @@ Ext.define('PBS.DataStoreContent', {
                     onVerify: createControllerCallback('onVerify'),
                     onChangeOwner: createControllerCallback('onChangeOwner'),
                     onPrune: createControllerCallback('onPrune'),
+                    onMove: createControllerCallback('onMove'),
                     onForget: createControllerCallback('onForget'),
                 });
             } else if (record.data.ty === 'dir') {
@@ -1086,6 +1108,20 @@ Ext.define('PBS.DataStoreContent', {
                             : 'pmx-hidden',
                     isActionDisabled: (v, r, c, i, rec) => !!rec.data.leaf,
                 },
+                {
+                    handler: 'onMove',
+                    getTip: (v, m, { data }) => {
+                        if (data.ty === 'group') {
+                            return Ext.String.format(gettext("Move group '{0}'"), v);
+                        }
+                        return '';
+                    },
+                    getClass: (v, m, { data }) => {
+                        if (data.ty === 'group') { return 'fa fa-arrows'; }
+                        return 'pmx-hidden';
+                    },
+                    isActionDisabled: (v, r, c, i, { data }) => false,
+                },
                 {
                     handler: 'onChangeOwner',
                     getClass: (v, m, { data }) =>
@@ -1438,6 +1474,7 @@ Ext.define('PBS.datastore.GroupCmdMenu', {
     onVerify: undefined,
     onChangeOwner: undefined,
     onPrune: undefined,
+    onMove: undefined,
     onForget: undefined,
 
     items: [
@@ -1461,6 +1498,16 @@ Ext.define('PBS.datastore.GroupCmdMenu', {
                 hidden: '{!onVerify}',
             },
         },
+        {
+            text: gettext('Move'),
+            iconCls: 'fa fa-arrows',
+            handler: function () {
+                this.up('menu').onMove();
+            },
+            cbind: {
+                hidden: '{!onMove}',
+            },
+        },
         {
             text: gettext('Change owner'),
             iconCls: 'fa fa-user',
diff --git a/www/window/GroupMove.js b/www/window/GroupMove.js
new file mode 100644
index 00000000..f663c606
--- /dev/null
+++ b/www/window/GroupMove.js
@@ -0,0 +1,56 @@
+Ext.define('PBS.window.GroupMove', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pbsGroupMove',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage-namespaces',
+
+    isCreate: true,
+    submitText: gettext('Move'),
+    showTaskViewer: true,
+
+    cbind: {
+        url: '/api2/extjs/admin/datastore/{datastore}/groups',
+        title: (get) => Ext.String.format(gettext("Move Backup Group '{0}'"), get('group')),
+    },
+    method: 'PUT',
+
+    width: 400,
+    fieldDefaults: {
+        labelWidth: 120,
+    },
+
+    items: {
+        xtype: 'inputpanel',
+        onGetValues: function (values) {
+            let win = this.up('window');
+            let result = {
+                'backup-type': win.backupType,
+                'backup-id': win.backupId,
+                'new-ns': values['new-ns'] || '',
+            };
+            if (win.namespace && win.namespace !== '') {
+                result.ns = win.namespace;
+            }
+            return result;
+        },
+        items: [
+            {
+                xtype: 'displayfield',
+                fieldLabel: gettext('Group'),
+                cbind: {
+                    value: '{group}',
+                },
+            },
+            {
+                xtype: 'pbsNamespaceSelector',
+                name: 'new-ns',
+                fieldLabel: gettext('Target Namespace'),
+                allowBlank: true,
+                cbind: {
+                    datastore: '{datastore}',
+                },
+            },
+        ],
+    },
+});
-- 
2.47.3





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

* [PATCH proxmox-backup v6 7/8] ui: add move namespace action
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (5 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 6/8] ui: add move group action Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-04-02  9:28   ` Arthur Bied-Charreton
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands Hannes Laimer
  2026-04-02  9:34 ` [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Arthur Bied-Charreton
  8 siblings, 1 reply; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

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 PUT to the move_namespace API endpoint.

The source namespace and its descendants are excluded from the parent
selector to prevent cycles.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 www/Makefile                  |  1 +
 www/datastore/Content.js      | 27 +++++++++++-
 www/form/NamespaceSelector.js | 11 +++++
 www/window/NamespaceMove.js   | 79 +++++++++++++++++++++++++++++++++++
 4 files changed, 117 insertions(+), 1 deletion(-)
 create mode 100644 www/window/NamespaceMove.js

diff --git a/www/Makefile b/www/Makefile
index 7745d9f4..f2011af3 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/RemoteEdit.js				\
diff --git a/www/datastore/Content.js b/www/datastore/Content.js
index 585a1a2d..b53f9b67 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,10 +1136,13 @@ 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 }) => false,
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..415d509d
--- /dev/null
+++ b/www/window/NamespaceMove.js
@@ -0,0 +1,79 @@
+Ext.define('PBS.window.NamespaceMove', {
+    extend: 'Proxmox.window.Edit',
+    alias: 'widget.pbsNamespaceMove',
+    mixins: ['Proxmox.Mixin.CBind'],
+
+    onlineHelp: 'storage-namespaces',
+
+    submitText: gettext('Move'),
+    isCreate: true,
+    showTaskViewer: true,
+
+    cbind: {
+        url: '/api2/extjs/admin/datastore/{datastore}/namespace',
+        title: (get) => Ext.String.format(gettext("Move Namespace '{0}'"), get('namespace')),
+    },
+    method: 'PUT',
+
+    width: 450,
+    fieldDefaults: {
+        labelWidth: 120,
+    },
+
+    cbindData: function (initialConfig) {
+        let ns = initialConfig.namespace ?? '';
+        let parts = ns.split('/');
+        return { nsName: parts[parts.length - 1] };
+    },
+
+    // Returns the new-ns path that was submitted, for use by the caller after success.
+    getNewNamespace: function () {
+        let me = this;
+        let parent = me.down('[name=parent]').getValue() || '';
+        let name = me.down('[name=name]').getValue();
+        return parent ? `${parent}/${name}` : name;
+    },
+
+    items: {
+        xtype: 'inputpanel',
+        onGetValues: function (values) {
+            let parent = values.parent || '';
+            let newNs = parent ? `${parent}/${values.name}` : values.name;
+            return {
+                ns: this.up('window').namespace,
+                'new-ns': newNs,
+            };
+        },
+        items: [
+            {
+                xtype: 'displayfield',
+                fieldLabel: gettext('Namespace'),
+                cbind: {
+                    value: '{namespace}',
+                },
+            },
+            {
+                xtype: 'pbsNamespaceSelector',
+                name: 'parent',
+                fieldLabel: gettext('New Parent'),
+                allowBlank: true,
+                cbind: {
+                    datastore: '{datastore}',
+                    excludeNs: '{namespace}',
+                },
+            },
+            {
+                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}',
+                },
+            },
+        ],
+    },
+});
-- 
2.47.3





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

* [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (6 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 7/8] ui: add move namespace action Hannes Laimer
@ 2026-03-31 12:34 ` Hannes Laimer
  2026-04-02  9:22   ` Arthur Bied-Charreton
  2026-04-02  9:34 ` [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Arthur Bied-Charreton
  8 siblings, 1 reply; 12+ messages in thread
From: Hannes Laimer @ 2026-03-31 12:34 UTC (permalink / raw)
  To: pbs-devel

Add 'move-namespace' and 'move-group' subcommands to
proxmox-backup-manager datastore. Both call the corresponding API
handler and wait for the worker task to complete.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/bin/proxmox_backup_manager/datastore.rs | 84 ++++++++++++++++++++-
 1 file changed, 83 insertions(+), 1 deletion(-)

diff --git a/src/bin/proxmox_backup_manager/datastore.rs b/src/bin/proxmox_backup_manager/datastore.rs
index 5c65c5ec..2ee56fba 100644
--- a/src/bin/proxmox_backup_manager/datastore.rs
+++ b/src/bin/proxmox_backup_manager/datastore.rs
@@ -1,5 +1,6 @@
 use pbs_api_types::{
-    DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA,
+    BackupNamespace, DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA,
+    PROXMOX_CONFIG_DIGEST_SCHEMA,
 };
 use pbs_client::view_task_result;
 use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
@@ -323,6 +324,75 @@ async fn uuid_mount(mut param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Resul
     Ok(Value::Null)
 }
 
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            store: {
+                schema: DATASTORE_SCHEMA,
+            },
+            ns: {
+                type: BackupNamespace,
+            },
+            "new-ns": {
+                type: BackupNamespace,
+            },
+        },
+    },
+)]
+/// Move a backup namespace to a new location within the same datastore.
+async fn cli_move_namespace(
+    mut param: Value,
+    rpcenv: &mut dyn RpcEnvironment,
+) -> Result<(), Error> {
+    param["node"] = "localhost".into();
+
+    let info = &api2::admin::namespace::API_METHOD_MOVE_NAMESPACE;
+    let result = match info.handler {
+        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+        _ => unreachable!(),
+    };
+
+    crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+    Ok(())
+}
+
+#[api(
+    protected: true,
+    input: {
+        properties: {
+            store: {
+                schema: DATASTORE_SCHEMA,
+            },
+            ns: {
+                type: BackupNamespace,
+                optional: true,
+            },
+            group: {
+                type: pbs_api_types::BackupGroup,
+                flatten: true,
+            },
+            "new-ns": {
+                type: BackupNamespace,
+                optional: true,
+            },
+        },
+    },
+)]
+/// Move a backup group to a different namespace within the same datastore.
+async fn cli_move_group(mut param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
+    param["node"] = "localhost".into();
+
+    let info = &api2::admin::datastore::API_METHOD_MOVE_GROUP;
+    let result = match info.handler {
+        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
+        _ => unreachable!(),
+    };
+
+    crate::wait_for_local_worker(result.as_str().unwrap()).await?;
+    Ok(())
+}
+
 #[api(
     protected: true,
     input: {
@@ -407,6 +477,18 @@ pub fn datastore_commands() -> CommandLineInterface {
             CliCommand::new(&API_METHOD_DELETE_DATASTORE)
                 .arg_param(&["name"])
                 .completion_cb("name", pbs_config::datastore::complete_datastore_name),
+        )
+        .insert(
+            "move-namespace",
+            CliCommand::new(&API_METHOD_CLI_MOVE_NAMESPACE)
+                .arg_param(&["store"])
+                .completion_cb("store", pbs_config::datastore::complete_datastore_name),
+        )
+        .insert(
+            "move-group",
+            CliCommand::new(&API_METHOD_CLI_MOVE_GROUP)
+                .arg_param(&["store"])
+                .completion_cb("store", pbs_config::datastore::complete_datastore_name),
         );
 
     cmd_def.into()
-- 
2.47.3





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

* Re: [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands Hannes Laimer
@ 2026-04-02  9:22   ` Arthur Bied-Charreton
  0 siblings, 0 replies; 12+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-02  9:22 UTC (permalink / raw)
  To: Hannes Laimer; +Cc: pbs-devel

On Tue, Mar 31, 2026 at 02:34:09PM +0200, Hannes Laimer wrote:
> Add 'move-namespace' and 'move-group' subcommands to
> proxmox-backup-manager datastore. Both call the corresponding API
> handler and wait for the worker task to complete.
> 
> Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
Hey, one comment & one nit inline
> ---
>  src/bin/proxmox_backup_manager/datastore.rs | 84 ++++++++++++++++++++-
>  1 file changed, 83 insertions(+), 1 deletion(-)
> 
> diff --git a/src/bin/proxmox_backup_manager/datastore.rs b/src/bin/proxmox_backup_manager/datastore.rs
> index 5c65c5ec..2ee56fba 100644
> --- a/src/bin/proxmox_backup_manager/datastore.rs
> +++ b/src/bin/proxmox_backup_manager/datastore.rs
> @@ -1,5 +1,6 @@
>  use pbs_api_types::{
> -    DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA, PROXMOX_CONFIG_DIGEST_SCHEMA,
> +    BackupNamespace, DataStoreConfig, DataStoreConfigUpdater, DATASTORE_SCHEMA,
> +    PROXMOX_CONFIG_DIGEST_SCHEMA,
>  };
>  use pbs_client::view_task_result;
>  use proxmox_router::{cli::*, ApiHandler, RpcEnvironment};
> @@ -323,6 +324,75 @@ async fn uuid_mount(mut param: Value, _rpcenv: &mut dyn RpcEnvironment) -> Resul
>      Ok(Value::Null)
>  }
>  
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            store: {
> +                schema: DATASTORE_SCHEMA,
> +            },
> +            ns: {
> +                type: BackupNamespace,
> +            },
> +            "new-ns": {
> +                type: BackupNamespace,
> +            },
> +        },
> +    },
> +)]
> +/// Move a backup namespace to a new location within the same datastore.
> +async fn cli_move_namespace(
> +    mut param: Value,
> +    rpcenv: &mut dyn RpcEnvironment,
> +) -> Result<(), Error> {
> +    param["node"] = "localhost".into();
> +
> +    let info = &api2::admin::namespace::API_METHOD_MOVE_NAMESPACE;
> +    let result = match info.handler {
> +        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
> +        _ => unreachable!(),
> +    };
> +
> +    crate::wait_for_local_worker(result.as_str().unwrap()).await?;
> +    Ok(())
> +}
nit: The `move-namespace` command outputs a log message from
`DataStore::with_store_and_config` ("using datastore cache ...").
That's unrelated to these changes, but it's a little confusing since
that ends up being the only output you get when moving a namespace.
IMO this would benefit from some kind of success message to make it 
clear that the move happened.
> +
> +#[api(
> +    protected: true,
> +    input: {
> +        properties: {
> +            store: {
> +                schema: DATASTORE_SCHEMA,
> +            },
> +            ns: {
> +                type: BackupNamespace,
> +                optional: true,
> +            },
> +            group: {
> +                type: pbs_api_types::BackupGroup,
> +                flatten: true,
> +            },
> +            "new-ns": {
> +                type: BackupNamespace,
> +                optional: true,
> +            },
> +        },
> +    },
> +)]
> +/// Move a backup group to a different namespace within the same datastore.
> +async fn cli_move_group(mut param: Value, rpcenv: &mut dyn RpcEnvironment) -> Result<(), Error> {
> +    param["node"] = "localhost".into();
> +
> +    let info = &api2::admin::datastore::API_METHOD_MOVE_GROUP;
> +    let result = match info.handler {
> +        ApiHandler::Sync(handler) => (handler)(param, info, rpcenv)?,
> +        _ => unreachable!(),
> +    };
> +
> +    crate::wait_for_local_worker(result.as_str().unwrap()).await?;
> +    Ok(())
> +}
It is not clear to me why not setting a source/target operand should
automatically imply the root namespace. I assume this is to reflect the
actual namespace structure, where root is actually stored as '', but I
think it would be cleaner if there was a way to make it explicit, or if
that's not possible (not sure if there is even a keyword we can reserve
for this), at least document it.
>  #[api(
>      protected: true,
>      input: {
> @@ -407,6 +477,18 @@ pub fn datastore_commands() -> CommandLineInterface {
>              CliCommand::new(&API_METHOD_DELETE_DATASTORE)
>                  .arg_param(&["name"])
>                  .completion_cb("name", pbs_config::datastore::complete_datastore_name),
> +        )
> +        .insert(
> +            "move-namespace",
> +            CliCommand::new(&API_METHOD_CLI_MOVE_NAMESPACE)
> +                .arg_param(&["store"])
> +                .completion_cb("store", pbs_config::datastore::complete_datastore_name),
> +        )
> +        .insert(
> +            "move-group",
> +            CliCommand::new(&API_METHOD_CLI_MOVE_GROUP)
> +                .arg_param(&["store"])
> +                .completion_cb("store", pbs_config::datastore::complete_datastore_name),
>          );
>  
>      cmd_def.into()
> -- 
> 2.47.3
> 
> 
> 
> 
> 




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

* Re: [PATCH proxmox-backup v6 7/8] ui: add move namespace action
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 7/8] ui: add move namespace action Hannes Laimer
@ 2026-04-02  9:28   ` Arthur Bied-Charreton
  0 siblings, 0 replies; 12+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-02  9:28 UTC (permalink / raw)
  To: Hannes Laimer; +Cc: pbs-devel

On Tue, Mar 31, 2026 at 02:34:08PM +0200, Hannes Laimer wrote:
> 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 PUT to the move_namespace API endpoint.
> 
> The source namespace and its descendants are excluded from the parent
> selector to prevent cycles.
> 
Hey, one comment inline :)
> Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
> ---
>  www/Makefile                  |  1 +
>  www/datastore/Content.js      | 27 +++++++++++-
>  www/form/NamespaceSelector.js | 11 +++++
>  www/window/NamespaceMove.js   | 79 +++++++++++++++++++++++++++++++++++
>  4 files changed, 117 insertions(+), 1 deletion(-)
>  create mode 100644 www/window/NamespaceMove.js
> 
> diff --git a/www/Makefile b/www/Makefile
> index 7745d9f4..f2011af3 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/RemoteEdit.js				\
> diff --git a/www/datastore/Content.js b/www/datastore/Content.js
> index 585a1a2d..b53f9b67 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,10 +1136,13 @@ 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';
> +                        }
The move action is only accessible when already inside the namespace
being moved. If I want to move `root/x`, I have to navigate to `x` first.
IMO it would be more intuitive/convenient to have the move button on the
`x` row in the root view as well. 
>                          return 'pmx-hidden';
>                      },
>                      isActionDisabled: (v, r, c, i, { data }) => false,
> 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..415d509d
> --- /dev/null
> +++ b/www/window/NamespaceMove.js
> @@ -0,0 +1,79 @@
> +Ext.define('PBS.window.NamespaceMove', {
> +    extend: 'Proxmox.window.Edit',
> +    alias: 'widget.pbsNamespaceMove',
> +    mixins: ['Proxmox.Mixin.CBind'],
> +
> +    onlineHelp: 'storage-namespaces',
> +
> +    submitText: gettext('Move'),
> +    isCreate: true,
> +    showTaskViewer: true,
> +
> +    cbind: {
> +        url: '/api2/extjs/admin/datastore/{datastore}/namespace',
> +        title: (get) => Ext.String.format(gettext("Move Namespace '{0}'"), get('namespace')),
> +    },
> +    method: 'PUT',
> +
> +    width: 450,
> +    fieldDefaults: {
> +        labelWidth: 120,
> +    },
> +
> +    cbindData: function (initialConfig) {
> +        let ns = initialConfig.namespace ?? '';
> +        let parts = ns.split('/');
> +        return { nsName: parts[parts.length - 1] };
> +    },
> +
> +    // Returns the new-ns path that was submitted, for use by the caller after success.
> +    getNewNamespace: function () {
> +        let me = this;
> +        let parent = me.down('[name=parent]').getValue() || '';
> +        let name = me.down('[name=name]').getValue();
> +        return parent ? `${parent}/${name}` : name;
> +    },
> +
> +    items: {
> +        xtype: 'inputpanel',
> +        onGetValues: function (values) {
> +            let parent = values.parent || '';
> +            let newNs = parent ? `${parent}/${values.name}` : values.name;
> +            return {
> +                ns: this.up('window').namespace,
> +                'new-ns': newNs,
> +            };
> +        },
> +        items: [
> +            {
> +                xtype: 'displayfield',
> +                fieldLabel: gettext('Namespace'),
> +                cbind: {
> +                    value: '{namespace}',
> +                },
> +            },
> +            {
> +                xtype: 'pbsNamespaceSelector',
> +                name: 'parent',
> +                fieldLabel: gettext('New Parent'),
> +                allowBlank: true,
> +                cbind: {
> +                    datastore: '{datastore}',
> +                    excludeNs: '{namespace}',
> +                },
> +            },
> +            {
> +                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}',
> +                },
> +            },
> +        ],
> +    },
> +});
> -- 
> 2.47.3
> 
> 
> 
> 
> 




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

* Re: [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces
  2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
                   ` (7 preceding siblings ...)
  2026-03-31 12:34 ` [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands Hannes Laimer
@ 2026-04-02  9:34 ` Arthur Bied-Charreton
  8 siblings, 0 replies; 12+ messages in thread
From: Arthur Bied-Charreton @ 2026-04-02  9:34 UTC (permalink / raw)
  To: Hannes Laimer; +Cc: pbs-devel

On Tue, Mar 31, 2026 at 02:34:01PM +0200, Hannes Laimer wrote:
> Add support for moving backup groups and entire namespace subtrees to
> a different location within the same datastore.
> 
> Groups are moved with exclusive per-group and per-snapshot locking.
> For S3, objects are copied to the target prefix before deleting the
> source. Namespace moves process groups individually, deferring and
> retrying lock conflicts once, so partially failed moves can be
> completed with move_group.
> 
> 
> v6, thanks @Fabian and @Dominik!:
>  - drop ns locks, lock everything directly, like we do for delete. only
>    difference, we dont do partial group moves, reason being that we
>    cant move single snapshots, so cleanup would not really be possible
>  - ui: disable purne for empty groups
>  - ui: dont render verification status for empty groups
> 
> v5, thanks @Chris!:
>  - lock dir instead of `.ns-lock` file
>  - explicitly drop ns lock guards in specific order
>  - improve cleanup of partially failed s3 moves, we now create the local
>    empty dir+owner file before we start copying s3 objects, if any of
>    the s3 ops fail, the dir stays behind and can be deleted through the
>    UI(which also triggers a prefix cleanup on the s3 storage)
>  - update parameters for `DataStore::lookup_datastore()`
>  - ui: re-ordered actions, `move` now next to `verify`
>  - ui: add move to right-click context menu
>  - ui: show empty groups in the UI
>  - add cli commands for both ns and group moves
>  - add 2s ns lock timeout for worker tasks
> 
> *note*: given the UI change to show empty groups it could make sense to
> not auto-delete a group if the last snapshot is deleted. For this series
> though that is not relevant since we just need empty groups to be
> deletable through the UI for partially failed s3 moves
>
[...]
Hey!

I played around with this, I think the UI looks nice and the feature
works well.  

Here's what I tested (on both normal and S3-backed datastores, both 
in the UI and with the CLI commands):

I can move namespaces/groups to other namespaces and to the root
namespace without issues. I was also able to rename a namespace
by moving it (like `mv`). I was not able to move a namespace/group 
if it would result in an ID collision in the target namespace, nor 
could I move a namespace to itself. I also could not move a group 
during a backup.

I did not find any issues while testing and the error messages
are helpful.

I answered {7,8}/8 with some UX nits, but in general I think
this looks nice, so consider this series:

Reviewed-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>
Tested-by: Arthur Bied-Charreton <a.bied-charreton@proxmox.com>




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

end of thread, other threads:[~2026-04-02  9:34 UTC | newest]

Thread overview: 12+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2026-03-31 12:34 [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 1/8] ui: show empty groups Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 2/8] datastore: add move_group Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 3/8] datastore: add move_namespace Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 4/8] api: add PUT endpoint for move_group Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 5/8] api: add PUT endpoint for move_namespace Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 6/8] ui: add move group action Hannes Laimer
2026-03-31 12:34 ` [PATCH proxmox-backup v6 7/8] ui: add move namespace action Hannes Laimer
2026-04-02  9:28   ` Arthur Bied-Charreton
2026-03-31 12:34 ` [PATCH proxmox-backup v6 8/8] cli: add move-namespace and move-group commands Hannes Laimer
2026-04-02  9:22   ` Arthur Bied-Charreton
2026-04-02  9:34 ` [PATCH proxmox-backup v6 0/8] fixes #6195: add support for moving groups and namespaces Arthur Bied-Charreton

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal