From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by lists.proxmox.com (Postfix) with ESMTPS id CB65A6B2E8 for ; Fri, 18 Mar 2022 14:52:45 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id C10DD1E34E for ; Fri, 18 Mar 2022 14:52:45 +0100 (CET) Received: from proxmox-new.maurer-it.com (proxmox-new.maurer-it.com [94.136.29.106]) (using TLSv1.3 with cipher TLS_AES_256_GCM_SHA384 (256/256 bits) key-exchange X25519 server-signature RSA-PSS (2048 bits)) (No client certificate requested) by firstgate.proxmox.com (Proxmox) with ESMTPS id E06431E2F9 for ; Fri, 18 Mar 2022 14:52:42 +0100 (CET) Received: from proxmox-new.maurer-it.com (localhost.localdomain [127.0.0.1]) by proxmox-new.maurer-it.com (Proxmox) with ESMTP id B860346EC1 for ; Fri, 18 Mar 2022 14:52:42 +0100 (CET) From: Matthias Heiserer To: pve-devel@lists.proxmox.com Date: Fri, 18 Mar 2022 14:52:22 +0100 Message-Id: <20220318135226.2360890-2-m.heiserer@proxmox.com> X-Mailer: git-send-email 2.30.2 In-Reply-To: <20220318135226.2360890-1-m.heiserer@proxmox.com> References: <20220318135226.2360890-1-m.heiserer@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.008 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% 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 T_SCC_BODY_TEXT_LINE -0.01 - Subject: [pve-devel] [PATCH manager 1/5 v2] Storage GUI: Rewrite backup content view as TreePanel. X-BeenThere: pve-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox VE development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , X-List-Received-Date: Fri, 18 Mar 2022 13:52:45 -0000 Should be easier to read/use than the current flat list. Backups are grouped by ID and type, so in case there are backups with ID 100 for both CT and VM, this would create two separate groups in the UI. Date and size of group are taken from the latest backup. Notes, Protection, Encrypted, and Verify State stay as default value empty, empty, No, and None, respectively. Code adapted from the existing backup view and the pbs datastore content, where appropriate. Signed-off-by: Matthias Heiserer --- Changes from v1: -add BackupNow button to create backups when not used for storage -Display combined size of backups for groups, except for PBS where deduplication happens -remove timeout for API call -inform window/restore if storage is PBS -use get_backup_type helper -some bug fixes, e.g. text was used instead of volid -remove groupField, doesn't do anything for TreeStore -only sort by text, ie. `(qemu|lxc)/` -use render_storage_content helper for displaying backup group -remove content from store as it's never used. everything is content-type backup because we get the data from API -group by backuptype rather than format, so that e.g tar and tar.zst are in the same group. -code cleanup: reorder statements, remove one-character variable, remove debug log, rename this to me, change filtering, rename notPBS to isPBS -add text field to model. fix date field in model -remember expanded nodes and selection -rename backup model -use filterer only with correct store www/manager6/storage/BackupView.js | 702 +++++++++++++++++++++-------- 1 file changed, 511 insertions(+), 191 deletions(-) diff --git a/www/manager6/storage/BackupView.js b/www/manager6/storage/BackupView.js index 2328c0fc..cbb1624b 100644 --- a/www/manager6/storage/BackupView.js +++ b/www/manager6/storage/BackupView.js @@ -1,223 +1,543 @@ -Ext.define('PVE.storage.BackupView', { - extend: 'PVE.storage.ContentView', +Ext.define('PVE.storage.BackupModel', { + extend: 'Ext.data.Model', + fields: [ + { + name: 'ctime', + type: 'date', + dateFormat: 'timestamp', + }, + 'format', + 'volid', + 'vmid', + 'size', + 'protected', + 'notes', + 'text', + ], +}); + +Ext.define('PVE.storage.BackupView', { + extend: 'Ext.tree.Panel', alias: 'widget.pveStorageBackupView', + mixins: ['Proxmox.Mixin.CBind'], + rootVisible: false, - showColumns: ['name', 'notes', 'protected', 'date', 'format', 'size'], + title: gettext('Content'), - initComponent: function() { - var me = this; + cbindData: function(initialCfg) { + this.isPBS = initialCfg.pluginType === 'pbs'; + return {}; + }, + isStorage: false, - var nodename = me.nodename = me.pveSelNode.data.node; - if (!nodename) { - throw "no node name specified"; - } + controller: { + xclass: 'Ext.app.ViewController', - var storage = me.storage = me.pveSelNode.data.storage; - if (!storage) { - throw "no storage ID specified"; - } + groupnameHelper: function(item) { + if (item.vmid) { + return PVE.Utils.get_backup_type(item.volid, item.format) + `/${item.vmid}`; + } else { + return 'Other'; + } + }, - me.content = 'backup'; + init: function(view) { + let me = this; + me.storage = view?.pveSelNode?.data?.storage; + me.nodename = view.nodename || view.pveSelNode.data.node; + me.vmid = view.pveSelNode.data.vmid; + me.vmtype = view.pveSelNode.data.type; - var sm = me.sm = Ext.create('Ext.selection.RowModel', {}); + me.store = Ext.create('Ext.data.Store', { + model: 'PVE.storage.BackupModel', + }); + me.store.on('load', me.onLoad, me); + view.getStore().setConfig('filterer', 'bottomup'); + view.getStore().setSorters(['text']); - var reload = function() { - me.store.load(); - }; + if (me.vmid) { + me.getView().getStore().filter({ + property: 'vmid', + value: me.vmid, + exactMatch: true, + }); + } else { + me.lookup('storagesel').setVisible(false); + me.lookup('backupNowButton').setVisible(false); + } + Proxmox.Utils.monStoreErrors(view, me.store); + }, - let pruneButton = Ext.create('Proxmox.button.Button', { - text: gettext('Prune group'), - disabled: true, - selModel: sm, - setBackupGroup: function(backup) { - if (backup) { - let name = backup.text; - let vmid = backup.vmid; - let format = backup.format; + onLoad: function(store, records, success, operation) { + let me = this; + let view = me.getView(); + let selection = view.getSelection()?.[0]; + selection = selection?.parentNode?.data?.text +selection?.data?.volid; - let vmtype; - if (name.startsWith('vzdump-lxc-') || format === "pbs-ct") { - vmtype = 'lxc'; - } else if (name.startsWith('vzdump-qemu-') || format === "pbs-vm") { - vmtype = 'qemu'; + let expanded = {}; + view.getRootNode().cascadeBy({ + before: item => { + if (item.isExpanded() && !item.data.leaf) { + let id = item.data.text; + expanded[id] = true; + return true; } + return false; + }, + after: Ext.emptyFn, + }); + let groups = me.getRecordGroups(records, expanded); - if (vmid && vmtype) { - this.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`); - this.vmid = vmid; - this.vmtype = vmtype; - this.setDisabled(false); - return; - } + for (const item of records.map(i => i.data)) { + item.text = item.volid; + item.leaf = true; + item.iconCls = 'fa-file-o'; + groups[me.groupnameHelper(item)].children.push(item); + groups[me.groupnameHelper(item)].size += item.size; + } + + for (let [_name, group] of Object.entries(groups)) { + let children = group.children; + let latest = children.reduce((l, r) => l.ctime > r.ctime ? l : r); + group.ctime = latest.ctime; + if (view.isPBS) { + group.size = latest.size; } - this.setText(gettext('Prune group')); - this.vmid = null; - this.vmtype = null; - this.setDisabled(true); - }, - handler: function(b, e, rec) { - let win = Ext.create('PVE.window.Prune', { - nodename: nodename, - storage: storage, - backup_id: this.vmid, - backup_type: this.vmtype, + let num_verified = children.reduce((l, r) => l + r.verification === 'ok', 0); + group.verified = num_verified / children.length; + } + + let children = []; + Object.entries(groups).forEach(e => children.push(e[1])); + view.setRootNode({ + expanded: true, + children: children, + }); + + if (selection) { + let rootnode = view.getRootNode(); + let selected; + rootnode.cascade(node => { + if (selected) {return false;} // skip if already found + let id = node.parentNode?.data?.text + node.data?.volid; + if (id === selection) { + selected = node; + return false; + } + return true; }); - win.show(); - win.on('destroy', reload); - }, - }); + view.setSelection(selected); + view.getView().focusRow(selected); + } + Proxmox.Utils.setErrorMask(view, false); + }, - me.on('selectionchange', function(model, srecords, eOpts) { - if (srecords.length === 1) { - pruneButton.setBackupGroup(srecords[0].data); - } else { - pruneButton.setBackupGroup(null); + reload: function() { + let me = this; + let view = me.getView(); + if (!view.store || !me.store) { + console.warn('cannot reload, no store(s)'); + return; } - }); - - let isPBS = me.pluginType === 'pbs'; - - me.tbar = [ - { - xtype: 'proxmoxButton', - text: gettext('Restore'), - selModel: sm, - disabled: true, - handler: function(b, e, rec) { - var vmtype; - if (PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format)) { - vmtype = 'qemu'; - } else if (PVE.Utils.volume_is_lxc_backup(rec.data.volid, rec.data.format)) { - vmtype = 'lxc'; - } else { - return; - } - var win = Ext.create('PVE.window.Restore', { - nodename: nodename, - volid: rec.data.volid, - volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), - vmtype: vmtype, - isPBS: isPBS, - }); - win.show(); - win.on('destroy', reload); - }, - }, - ]; - if (isPBS) { - me.tbar.push({ - xtype: 'proxmoxButton', - text: gettext('File Restore'), - disabled: true, - selModel: sm, - handler: function(b, e, rec) { - let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format); - Ext.create('Proxmox.window.FileBrowser', { - title: gettext('File Restore') + " - " + rec.data.text, - listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`, - downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`, - extraParams: { - volume: rec.data.volid, - }, - archive: isVMArchive ? 'all' : undefined, - autoShow: true, - }); + if (!me.storage) { + Proxmox.Utils.setErrorMask(view, true); + return; + } + + let url = `/api2/json/nodes/${me.nodename}/storage/${me.storage}/content`; + me.store.setProxy({ + type: 'proxmox', + url: url, + extraParams: { + content: 'backup', }, }); - } - me.tbar.push( - { - xtype: 'proxmoxButton', - text: gettext('Show Configuration'), - disabled: true, - selModel: sm, - handler: function(b, e, rec) { - var win = Ext.create('PVE.window.BackupConfig', { - volume: rec.data.volid, - pveSelNode: me.pveSelNode, - }); - - win.show(); + + me.store.load(); + Proxmox.Utils.monStoreErrors(view, me.store); + }, + + getRecordGroups: function(records, expanded) { + let groups = {}; + for (const item of records) { + const groupName = this.groupnameHelper(item.data); + groups[groupName] = { + vmid: item.data.vmid, + leaf: false, + children: [], + expanded: !!expanded[groupName], + text: groupName, + ctime: 0, + format: item.data.format, + volid: item.data.volid, // to preserve backup type information + size: 0, + iconCls: PVE.Utils.get_backup_type_icon_cls(item.data.volid, item.data.format), + }; + } + return groups; + }, + + restoreHandler: function(button, event, rec) { + let me = this; + let vmtype = PVE.Utils.get_backup_type(rec.data.volid, rec.data.format); + let win = Ext.create('PVE.window.Restore', { + nodename: me.nodename, + volid: rec.data.volid, + volidText: PVE.Utils.render_storage_content(rec.data.volid, {}, rec), + vmtype: vmtype, + isPBS: me.view.isPBS, + }); + win.on('destroy', () => me.reload()); + win.show(); + }, + + restoreFilesHandler: function(button, event, rec) { + let me = this; + let isVMArchive = PVE.Utils.volume_is_qemu_backup(rec.data.volid, rec.data.format); + Ext.create('Proxmox.window.FileBrowser', { + title: gettext('File Restore') + " - " + rec.data.text, + listURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/list`, + downloadURL: `/api2/json/nodes/localhost/storage/${me.storage}/file-restore/download`, + extraParams: { + volume: rec.data.volid, }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Edit Notes'), - disabled: true, - selModel: sm, - handler: function(b, e, rec) { - let volid = rec.data.volid; - Ext.create('Proxmox.window.Edit', { - autoLoad: true, - width: 600, - height: 400, - resizable: true, - title: gettext('Notes'), - url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, + archive: isVMArchive ? 'all' : undefined, + autoShow: true, + }); + }, + + showConfigurationHandler: function(button, event, rec) { + let win = Ext.create('PVE.window.BackupConfig', { + volume: rec.data.volid, + pveSelNode: this.view.pveSelNode, + }); + win.show(); + }, + + editNotesHandler: function(button, event, rec) { + let me = this; + let volid = rec.data.volid; + Ext.create('Proxmox.window.Edit', { + autoLoad: true, + width: 600, + height: 400, + resizable: true, + title: gettext('Notes'), + url: `/api2/extjs/nodes/${me.nodename}/storage/${me.storage}/content/${volid}`, + layout: 'fit', + items: [ + { + xtype: 'textarea', layout: 'fit', - items: [ - { - xtype: 'textarea', - layout: 'fit', - name: 'notes', - height: '100%', - }, - ], - listeners: { - destroy: () => reload(), - }, - }).show(); + name: 'notes', + height: '100%', + }, + ], + listeners: { + destroy: () => me.reload(), }, - }, - { - xtype: 'proxmoxButton', - text: gettext('Change Protection'), - disabled: true, - handler: function(button, event, record) { - const volid = record.data.volid; - Proxmox.Utils.API2Request({ - url: `/api2/extjs/nodes/${nodename}/storage/${me.storage}/content/${volid}`, - method: 'PUT', - waitMsgTarget: me, - params: { 'protected': record.data.protected ? 0 : 1 }, - failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), - success: (response) => reload(), - }); + }).show(); + }, + + changeProtectionHandler: function(button, event, rec) { + let me = this; + const volid = rec.data.volid; + Proxmox.Utils.API2Request({ + url: `/api2/extjs/nodes/${me.nodename}/storage/${me.storage}/content/${volid}`, + method: 'PUT', + waitMsgTarget: button, + params: { 'protected': rec.data.protected ? 0 : 1 }, + failure: (response) => Ext.Msg.alert('Error', response.htmlStatus), + success: (_) => me.reload(), + }); + }, + + pruneGroupHandler: function(button, event, rec) { + let me = this; + let vmtype = PVE.Utils.get_backup_type(rec.data.volid, rec.data.format); + Ext.create('PVE.window.Prune', { + nodename: me.nodename, + storage: me.storage, + backup_id: rec.data.vmid, + backup_type: vmtype, + rec: rec, + listeners: { + destroy: () => me.reload(), }, - }, - '-', - pruneButton, - ); - - if (isPBS) { - me.extraColumns = { - encrypted: { - header: gettext('Encrypted'), - dataIndex: 'encrypted', - renderer: PVE.Utils.render_backup_encryption, + }).show(); + }, + + removeHandler: function(button, event, rec) { + let me = this; + const volid = rec.data.volid; + Proxmox.Utils.API2Request({ + url: `/nodes/${me.nodename}/storage/${me.storage}/content//${volid}`, + method: 'DELETE', + callback: () => me.reload(), + failure: response => Ext.Msg.alert(gettext('Error'), response.htmlStatus), + }); + }, + + searchKeyupFn: function(field) { + let me = this; + me.getView().getStore().getFilters().removeByKey('volid'); + me.getView().getStore().filter([ + { + property: 'volid', + value: field.getValue(), + anyMatch: true, + caseSensitive: false, + id: 'volid', }, - verification: { - header: gettext('Verify State'), - dataIndex: 'verification', - renderer: PVE.Utils.render_backup_verification, + ]); + }, + + searchClearHandler: function(field) { + field.triggers.clear.setVisible(false); + field.setValue(this.originalValue); + this.getView().getStore().clearFilter(); + }, + + searchChangeFn: function(field, newValue, oldValue) { + if (newValue !== field.originalValue) { + field.triggers.clear.setVisible(true); + } + }, + + storageSelectorBoxReady: function(selector, width, height, eOpts) { + selector.setNodename(this.nodename); + }, + + storageSelectorChange: function(self, newValue, oldValue, eOpts) { + let me = this; + me.storage = newValue; + me.getView().getSelectionModel().deselectAll(); + me.reload(); + }, + + backupNowHandler: function(button, event) { + let me = this; + Ext.create('PVE.window.Backup', { + nodename: me.nodename, + vmid: me.vmid, + vmtype: me.vmtype, + storage: me.storage, + listeners: { + close: () => me.reload(), }, - }; - } + }).show(); + }, + }, + + columns: [ + { + xtype: 'treecolumn', + header: gettext("Backup Group"), + dataIndex: 'text', + renderer: function(value, _metadata, record) { + if (record.phantom) { return value; } + return PVE.Utils.render_storage_content(...arguments); + }, + flex: 2, + }, + { + header: gettext('Notes'), + flex: 1, + renderer: Ext.htmlEncode, + dataIndex: 'notes', + }, + { + header: ``, + tooltip: gettext('Protected'), + width: 30, + renderer: v => v ? `` : '', + sorter: (a, b) => (b.data.protected || 0) - (a.data.protected || 0), + dataIndex: 'protected', + }, + { + header: gettext('Date'), + width: 150, + dataIndex: 'ctime', + xtype: 'datecolumn', + format: 'Y-m-d H:i:s', + }, + { + header: gettext('Format'), + width: 100, + dataIndex: 'format', + }, + { + header: gettext('Size'), + width: 100, + renderer: Proxmox.Utils.format_size, + dataIndex: 'size', + }, + { + header: gettext('Encrypted'), + dataIndex: 'encrypted', + renderer: PVE.Utils.render_backup_encryption, + cbind: { + hidden: '{!isPBS}', + }, + }, + { + header: gettext('Verify State'), + dataIndex: 'verification', + renderer: PVE.Utils.render_backup_verification, + cbind: { + hidden: '{!isPBS}', + }, + }, + ], + + tbar: [ + { + xtype: 'button', + text: gettext('Backup now'), + handler: 'backupNowHandler', + reference: 'backupNowButton', + }, + { + xtype: 'proxmoxButton', + text: gettext('Restore'), + handler: 'restoreHandler', + parentXType: "treepanel", + disabled: true, + enableFn: record => record.phantom === false, + }, + { + xtype: 'proxmoxButton', + text: gettext('File Restore'), + handler: 'restoreFilesHandler', + cbind: { + hidden: '{!isPBS}', + }, + parentXType: "treepanel", + disabled: true, + enableFn: record => record.phantom === false, + }, + { + xtype: 'proxmoxButton', + text: gettext('Show Configuration'), + handler: 'showConfigurationHandler', + parentXType: "treepanel", + disabled: true, + enableFn: record => record.phantom === false, + }, + { + xtype: 'proxmoxButton', + text: gettext('Edit Notes'), + handler: 'editNotesHandler', + parentXType: "treepanel", + disabled: true, + enableFn: record => record.phantom === false, + }, + { + xtype: 'proxmoxButton', + text: gettext('Change Protection'), + handler: 'changeProtectionHandler', + parentXType: "treepanel", + disabled: true, + enableFn: record => record.phantom === false, + }, + '-', + { + xtype: 'proxmoxButton', + text: gettext('Prune group'), + setBackupGroup: function(backup) { + let me = this; + if (backup) { + let volid = backup.volid; + let vmid = backup.vmid; + let format = backup.format; - me.callParent(); + let vmtype = PVE.Utils.get_backup_type(volid, format); + if (vmid && vmtype) { + me.setText(gettext('Prune group') + ` ${vmtype}/${vmid}`); + me.vmid = vmid; + me.vmtype = vmtype; + me.setDisabled(false); + return; + } + } + me.setText(gettext('Prune group')); + me.vmid = null; + me.vmtype = null; + me.setDisabled(true); + }, + handler: 'pruneGroupHandler', + parentXType: "treepanel", + disabled: true, + reference: 'pruneButton', + enableFn: () => true, + }, + { + xtype: 'proxmoxButton', + text: gettext('Remove'), + handler: 'removeHandler', + parentXType: 'treepanel', + disabled: true, + enableFn: record => record.phantom === false && !record?.data?.protected, + confirmMsg: function(rec) { + let name = rec.data.text; + return Ext.String.format(gettext('Are you sure you want to remove entry {0}'), `'${name}'`); + }, + }, + '->', + { + xtype: 'pveStorageSelector', + fieldLabel: gettext('Storage'), + storageContent: 'backup', + reference: 'storagesel', + listeners: { + change: 'storageSelectorChange', + boxready: 'storageSelectorBoxReady', + }, - me.store.getSorters().clear(); - me.store.setSorters([ - { - property: 'vmid', - direction: 'ASC', + }, + gettext('Search') + ':', + ' ', + { + xtype: 'textfield', + width: 200, + enableKeyEvents: true, + emptyText: gettext('Name, Format'), + listeners: { + keyup: { + buffer: 500, + fn: 'searchKeyupFn', + }, + change: 'searchChangeFn', }, - { - property: 'vdate', - direction: 'DESC', + triggers: { + clear: { + cls: 'pmx-clear-trigger', + weight: -1, + hidden: true, + handler: 'searchClearHandler', + }, }, - ]); + }, + ], + + listeners: { + activate: function() { + let me = this; + // only load on first activate to not load every tab switch + if (!me.firstLoad) { + me.getController().reload(); + me.firstLoad = true; + } + }, + selectionchange: function(model, srecords, eOpts) { + let pruneButton = this.getController().lookup('pruneButton'); + if (srecords.length === 1) { + pruneButton.setBackupGroup(srecords[0].data); + } else { + pruneButton.setBackupGroup(null); + } + }, }, }); -- 2.30.2