all lists on 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 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