public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
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





      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
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal