From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 769901FF0B3 for ; Wed, 14 Oct 2026 16:44:42 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 8A7822151B; Wed, 14 Oct 2026 16:44:33 +0200 (CEST) From: Christian Ebner To: pbs-devel@lists.proxmox.com Subject: [PATCH proxmox-backup v2 14/14] ui: implement statistics tab for datastore panel Date: Wed, 14 Oct 2026 16:43:57 +0200 Message-ID: <20261014144357.771051-15-c.ebner@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261014144357.771051-1-c.ebner@proxmox.com> References: <20261014144357.771051-1-c.ebner@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1791989044400 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.366 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_2 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust 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: FIPUZGS436WUZANLH5CQ2VH4LLBF4I6Q X-Message-ID-Hash: FIPUZGS436WUZANLH5CQ2VH4LLBF4I6Q X-MailFrom: c.ebner@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: Allows to inspect the statistics for individual items. Contents are collected and grouped client side to closely resemble the layout of the items as presented in contents tab. Signed-off-by: Christian Ebner --- www/Makefile | 1 + www/datastore/Panel.js | 8 + www/datastore/Statistics.js | 452 ++++++++++++++++++++++++++++++++++++ 3 files changed, 461 insertions(+) create mode 100644 www/datastore/Statistics.js diff --git a/www/Makefile b/www/Makefile index 568836455..a4a3acb16 100644 --- a/www/Makefile +++ b/www/Makefile @@ -123,6 +123,7 @@ JSSRC= \ datastore/Prune.js \ datastore/Content.js \ datastore/OptionView.js \ + datastore/Statistics.js \ datastore/Panel.js \ datastore/DataStoreListSummary.js \ datastore/DataStoreList.js \ diff --git a/www/datastore/Panel.js b/www/datastore/Panel.js index 68c1c1318..5972e98c7 100644 --- a/www/datastore/Panel.js +++ b/www/datastore/Panel.js @@ -99,6 +99,14 @@ Ext.define('PBS.DataStorePanel', { datastore: '{datastore}', }, }, + { + xtype: 'pbsDataStoreStatistics', + itemId: 'statistics', + iconCls: 'fa fa-bar-chart', + cbind: { + datastore: '{datastore}', + }, + }, ], initComponent: function () { diff --git a/www/datastore/Statistics.js b/www/datastore/Statistics.js new file mode 100644 index 000000000..4f5f6a9cd --- /dev/null +++ b/www/datastore/Statistics.js @@ -0,0 +1,452 @@ +Ext.define('pbs-data-store-statistics', { + extend: 'Ext.data.Model', + fields: [ + { name: 'ns', type: 'string', allowNull: true }, + { name: 'backup-type', type: 'string', allowNull: true }, + { name: 'backup-id', type: 'string', allowNull: true }, + { + name: 'backup-time', + type: 'date', + dateFormat: 'timestamp', + }, + { name: 'raw-size', type: 'int' }, + { name: 'logical-size', type: 'int' }, + { name: 'unique-raw-size', type: 'int' }, + { name: 'reference-count', type: 'int' }, + { name: 'unique-count', type: 'int' }, + { name: 'dedup-count', type: 'int' }, + { + name: 'ty', + type: 'string', + calculate: function (data) { + if (!data.ns) { + return; // unexpected + } else if (!data['backup-type'] || !data['backup-id']) { + if (data['backup-time']) { + return; //unexpected + } else { + return 'ns'; + } + } else if (data['backup-time']) { + return 'dir'; + } else { + return 'group'; + } + }, + }, + { + name: 'dedup-factor', + type: 'float', + calculate: function (data) { + if (data['raw-size'] > 0) { + let logical = Ext.Number.parseFloat(data['logical-size']); + let raw = Ext.Number.parseFloat(data['raw-size']); + return logical / raw; + } else { + return 1.0; + } + }, + }, + ], +}); + +Ext.define('PBS.DataStoreStatistics', { + extend: 'Ext.tree.Panel', + alias: 'widget.pbsDataStoreStatistics', + mixins: ['Proxmox.Mixin.CBind'], + + rootVisible: false, + + title: gettext('Statistics'), + + controller: { + xclass: 'Ext.app.ViewController', + + init: function (view) { + if (!view.datastore) { + throw 'no datastore specified'; + } + + this.store = Ext.create('Ext.data.Store', { + model: 'pbs-data-store-statistics', + groupField: 'backup-group', + }); + this.store.on('load', this.onLoad, this); + + view.getStore().setSorters(['sortWeight', 'text', 'backup-time']); + }, + + control: { + '#': { + rowdblclick: 'rowDoubleClicked', + }, + }, + + rowDoubleClicked: function (table, rec, el, rowId, ev) { + if (rec?.data?.ty === 'ns' && !rec.data.root) { + this.nsChange(null, rec.data.ns); + } + }, + + nsChange: function (field, value) { + let view = this.getView(); + view.namespace = value; + this.reload(); + }, + + reload: function () { + let view = this.getView(); + if (!view.store || !this.store) { + console.warn('cannot reload, no store(s)'); + return; + } + + let url = `/api2/json/admin/datastore/${view.datastore}/list-statistics`; + this.store.setProxy({ + type: 'proxmox', + timeout: 60 * 1000, + url: url, + }); + + this.store.load(); + }, + + namespacedItems: function(parentNamespace, records) { + let ns = parentNamespace; + if (!parentNamespace) { + ns = ""; + } + let groups = {}; + let items = []; + let pos = ns.length === 0 ? ns.length : ns.length + 1; + + for (const item of records) { + let data = item.data; + if (!data.ns.startsWith(ns)) { + continue; + } + let slice = data.ns.substring(pos).split('/'); + if (slice.length !== 1) { + continue; // namespace level mismatch + } + + if (!data['backup-type'] || !data['backup-id']) { + if (!data['backup-time']) { + if (data.ns === ns) { + continue; + } + data.text = slice[0]; + data.iconCls = 'fa fa-object-group'; + data.expanded = true; + data.expandable = false; + data.sortWeight = 10; + data.leaf = true; + items.push(data); + } + continue; + } + + let key = data['backup-type'] + '/' + data['backup-id']; + + if (data['backup-time']) { + data.text = key + '/' + PBS.Utils.render_datetime_utc(data['backup-time']); + data.cls = 'no-leaf-icons'; + data.leaf = true; + data.matchesFilter = true; + data.expanded = false; + if (!groups[key]) { + groups[key] = { children: [] }; + } + if (!groups[key].children) { + groups[key].children = []; + } + groups[key].children.push(data); + } else { + let cls = PBS.Utils.get_type_icon_cls(data['backup-type']); + if (cls === '') { + continue; + } + + data.text = key; + data.leaf = false; + data.iconCls = 'fa ' + cls; + data.expanded = false; + data.sortWeight = 0; + + if (!groups[key]) { + groups[key] = data; + groups[key].children = []; + } else { + data.children = groups[key].children; + groups[key] = data; + } + } + } + + for (const [_name, group] of Object.entries(groups)) { + items.push(group); + } + + return items; + }, + + onLoad: async function (store, records, success, operation) { + let me = this; + let view = this.getView(); + + let children = me.namespacedItems(view.namespace, records); + + let isRootNS = !view.namespace || view.namespace === ''; + let rootText = isRootNS + ? gettext('Root Namespace') + : Ext.String.format(gettext("Namespace '{0}'"), view.namespace); + + let topNodes = []; + if (!isRootNS) { + let parentNS = view.namespace.split('/').slice(0, -1).join('/'); + topNodes.push({ + text: `.. (${parentNS === '' ? gettext('Root') : parentNS})`, + iconCls: 'fa fa-level-up', + ty: 'ns', + ns: parentNS, + sortWeight: -10, + leaf: true, + }); + } + topNodes.push({ + text: rootText, + iconCls: 'fa fa-' + (isRootNS ? 'database' : 'object-group'), + expanded: true, + expandable: false, + sortWeight: -5, + root: true, // fake root + isRootNS, + ty: 'ns', + children: children, + }); + + view.setRootNode({ + expanded: true, + children: topNodes, + }); + + if (view.getStore().getFilters().length > 0) { + let searchBox = me.lookup('searchbox'); + let searchvalue = searchBox.getValue(); + me.search(searchBox, searchvalue); + } + }, + + openNamespace: function (tv, rI, Ci, item, e, rec) { + let me = this; + if (rec.data.ty === 'ns') { + me.nsChange(null, rec.data.ns); + return; + } + }, + + filter: function (item, value) { + const needle = value.toLowerCase(); + return item.data.text.toLowerCase().includes(needle); + }, + + search: function (tf, value) { + let me = this; + let view = me.getView(); + let store = view.getStore(); + if (!value && value !== 0) { + store.clearFilter(); + // only collapse the children below our toplevel namespace "root" + store.getRoot().lastChild.collapseChildren(true); + // expand just the namespace "root", otherwise the navigation breaks, + // as the children are not accessible anymore + store.getRoot().lastChild.expand(); + tf.triggers.clear.setVisible(false); + return; + } + tf.triggers.clear.setVisible(true); + if (value.length < 2) { + return; + } + Proxmox.Utils.setErrorMask(view, true); + // we do it a little bit later for the error mask to work + setTimeout(function () { + store.clearFilter(); + store.getRoot().collapseChildren(true); + + store.beginUpdate(); + store.getRoot().cascadeBy({ + before: function (item) { + if (me.filter(item, value)) { + item.set('matchesFilter', true); + if (item.parentNode && item.parentNode.id !== 'root') { + item.parentNode.childmatches = true; + } + return false; + } + return true; + }, + after: function (item) { + if (me.filter(item, value) || item.id === 'root' || item.childmatches) { + item.set('matchesFilter', true); + if (item.parentNode && item.parentNode.id !== 'root') { + item.parentNode.childmatches = true; + } + if (item.childmatches) { + item.expand(); + } + } else { + item.set('matchesFilter', false); + } + delete item.childmatches; + }, + }); + store.endUpdate(); + + store.filter((item) => !!item.get('matchesFilter')); + Proxmox.Utils.setErrorMask(view, false); + }, 10); + }, + }, + + listeners: { + activate: function() { + this.getController().reload(); + }, + }, + + columns: [ + { + xtype: 'treecolumn', + header: gettext('Backup Group'), + dataIndex: 'text', + flex: 1, + }, + { + header: gettext('Actions'), + xtype: 'actioncolumn', + dataIndex: 'text', + width: 100, + items: [ + { + handler: 'openNamespace', + tooltip: gettext('Browse Namespace'), + getClass: (v, m, { data }) => { + if (data.ty === 'ns' && !data.root) { + return 'fa fa-folder-open-o'; + } + return 'pmx-hidden'; + }, + isActionDisabled: (v, r, c, i, { data }) => { + return data.ty !== 'ns' || data.root; + }, + }, + ], + }, + { + xtype: 'datecolumn', + header: gettext('Backup Time'), + dataIndex: 'backup-time', + format: 'Y-m-d H:i:s', + sortable: true, + width: 150, + }, + { + header: gettext('Raw Size'), + dataIndex: 'raw-size', + renderer: (val) => (!val) ? '' : Proxmox.Utils.format_size(val), + sortable: true, + align: 'right', + }, + { + header: gettext('Logical Size'), + dataIndex: 'logical-size', + renderer: (val) => (!val) ? '' : Proxmox.Utils.format_size(val), + sortable: true, + align: 'right', + }, + { + header: gettext('Unique Raw Size'), + dataIndex: 'unique-raw-size', + renderer: (val) => (!val) ? '' : Proxmox.Utils.format_size(val), + sortable: true, + align: 'right', + }, + { + xtype: 'numbercolumn', + header: gettext('Reference Count'), + dataIndex: 'reference-count', + format: '0', + sortable: true, + width: 150, + align: 'right', + }, + { + xtype: 'numbercolumn', + header: gettext('Unique Count'), + dataIndex: 'unique-count', + format: '0', + sortable: true, + width: 150, + align: 'right', + }, + { + xtype: 'numbercolumn', + header: gettext('Deduplication Count'), + dataIndex: 'dedup-count', + format: '0', + sortable: true, + width: 150, + align: 'right', + }, + { + xtype: 'numbercolumn', + header: gettext('Deduplication Factor'), + dataIndex: 'dedup-factor', + format: '0.00', + sortable: true, + width: 150, + align: 'right', + }, + ], + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + '-', + { + xtype: 'tbtext', + html: gettext('Shown statistics are not live, but generated by last garbage collection'), + }, + '->', + { + xtype: 'tbtext', + html: gettext('Search'), + }, + { + xtype: 'textfield', + reference: 'searchbox', + emptyText: gettext('item name or date'), + triggers: { + clear: { + cls: 'pmx-clear-trigger', + weight: -1, + hidden: true, + handler: function () { + this.triggers.clear.setVisible(false); + this.setValue(''); + }, + }, + }, + listeners: { + change: { + fn: 'search', + buffer: 500, + }, + }, + }, + ], +}); -- 2.47.3