From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id B1C871FF137 for ; Tue, 31 Mar 2026 14:34:20 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 35F631C0FA; Tue, 31 Mar 2026 14:34:48 +0200 (CEST) From: Hannes Laimer To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup v6 1/8] ui: show empty groups Date: Tue, 31 Mar 2026 14:34:02 +0200 Message-ID: <20260331123409.198353-2-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260331123409.198353-1-h.laimer@proxmox.com> References: <20260331123409.198353-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: 1774960396896 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.084 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: U2AZ6LB57WKW7HEOEODO634VGXJMGVEN X-Message-ID-Hash: U2AZ6LB57WKW7HEOEODO634VGXJMGVEN 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: 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 --- 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) => ` ${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