From: Christian Ebner <c.ebner@proxmox.com>
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 [thread overview]
Message-ID: <20261014144357.771051-15-c.ebner@proxmox.com> (raw)
In-Reply-To: <20261014144357.771051-1-c.ebner@proxmox.com>
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 <c.ebner@proxmox.com>
---
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
prev parent reply other threads:[~2026-10-14 14:44 UTC|newest]
Thread overview: 15+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-10-14 14:43 [PATCH proxmox{,-backup} v2 00/14] fix #5799: gather datastore usage statistics on per-entity level Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox v2 01/14] pbs-api-types: avoid reimplementation of is_false helper Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox v2 02/14] pbs-api-types: add types for fine-grained datastore usage statistics Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox v2 03/14] pbs-api-types: add schema for datastore usage statistics listing Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox v2 04/14] pbs-api-types: add flag to control usage statistics collection Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 05/14] chunk store: restrict chunk sweep helper method to module parent Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 06/14] datastore: implement index map for compact chunk usage tracking Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 07/14] datastore: implement chunk map for collecting chunk usage statistics Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 08/14] datastore: cleanup persisted statistics during datastore destruction Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 09/14] fix #5799: GC: track chunk digests and accumulate statistics Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 10/14] api: config: allow modification of extended-usage-statistics flag Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 11/14] ui: expose extended usage statistics flag in datastore options Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 12/14] api: admin: add endpoint to fetch fine-grained datastore usage stats Christian Ebner
2026-10-14 14:43 ` [PATCH proxmox-backup v2 13/14] bin: manager: allow listing of extended usage statistics via cli Christian Ebner
2026-10-14 14:43 ` Christian Ebner [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20261014144357.771051-15-c.ebner@proxmox.com \
--to=c.ebner@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox