From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: <pbs-devel-bounces@lists.proxmox.com> Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id C50521FF165 for <inbox@lore.proxmox.com>; Thu, 8 May 2025 15:06:37 +0200 (CEST) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EC645FF6E; Thu, 8 May 2025 15:06:53 +0200 (CEST) From: Christian Ebner <c.ebner@proxmox.com> To: pbs-devel@lists.proxmox.com Date: Thu, 8 May 2025 15:05:53 +0200 Message-Id: <20250508130555.494782-20-c.ebner@proxmox.com> X-Mailer: git-send-email 2.39.5 In-Reply-To: <20250508130555.494782-1-c.ebner@proxmox.com> References: <20250508130555.494782-1-c.ebner@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.029 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy 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 Subject: [pbs-devel] [RFC v2 proxmox-backup 19/21] ui: add recover for trashed items tab to datastore panel X-BeenThere: pbs-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Backup Server development discussion <pbs-devel.lists.proxmox.com> List-Unsubscribe: <https://lists.proxmox.com/cgi-bin/mailman/options/pbs-devel>, <mailto:pbs-devel-request@lists.proxmox.com?subject=unsubscribe> List-Archive: <http://lists.proxmox.com/pipermail/pbs-devel/> List-Post: <mailto:pbs-devel@lists.proxmox.com> List-Help: <mailto:pbs-devel-request@lists.proxmox.com?subject=help> List-Subscribe: <https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel>, <mailto:pbs-devel-request@lists.proxmox.com?subject=subscribe> Reply-To: Proxmox Backup Server development discussion <pbs-devel@lists.proxmox.com> Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pbs-devel-bounces@lists.proxmox.com Sender: "pbs-devel" <pbs-devel-bounces@lists.proxmox.com> Display a dedicated recover trashed tab which allows to inspect and recover trashed items. This is based on the pre-existing contents tab but drops any actions which make no sense for the given context, such as editing of group ownership, notes, verification, ecc. Signed-off-by: Christian Ebner <c.ebner@proxmox.com> --- www/Makefile | 1 + www/datastore/Panel.js | 8 + www/datastore/RecoverTrashed.js | 876 ++++++++++++++++++++++++++++++++ 3 files changed, 885 insertions(+) create mode 100644 www/datastore/RecoverTrashed.js diff --git a/www/Makefile b/www/Makefile index 44c5fa133..aa8955460 100644 --- a/www/Makefile +++ b/www/Makefile @@ -115,6 +115,7 @@ JSSRC= \ datastore/Panel.js \ datastore/DataStoreListSummary.js \ datastore/DataStoreList.js \ + datastore/RecoverTrashed.js \ ServerStatus.js \ ServerAdministration.js \ NodeNotes.js \ diff --git a/www/datastore/Panel.js b/www/datastore/Panel.js index ad9fc10fe..386b62284 100644 --- a/www/datastore/Panel.js +++ b/www/datastore/Panel.js @@ -99,6 +99,14 @@ Ext.define('PBS.DataStorePanel', { datastore: '{datastore}', }, }, + { + xtype: 'pbsDataStoreRecoverTrashed', + itemId: 'trashed', + iconCls: 'fa fa-rotate-left', + cbind: { + datastore: '{datastore}', + }, + }, ], initComponent: function() { diff --git a/www/datastore/RecoverTrashed.js b/www/datastore/RecoverTrashed.js new file mode 100644 index 000000000..2257a8cd3 --- /dev/null +++ b/www/datastore/RecoverTrashed.js @@ -0,0 +1,876 @@ +Ext.define('PBS.DataStoreRecoverTrashed', { + extend: 'Ext.tree.Panel', + alias: 'widget.pbsDataStoreRecoverTrashed', + mixins: ['Proxmox.Mixin.CBind'], + + rootVisible: false, + + title: gettext('Recover Trashed'), + + 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-snapshots', + groupField: 'backup-group', + }); + this.store.on('load', this.onLoad, this); + + view.getStore().setSorters([ + 'sortWeight', + 'text', + 'backup-time', + ]); + }, + + control: { + '#': { // view + rowdblclick: 'rowDoubleClicked', + }, + 'pbsNamespaceSelector': { + change: 'nsChange', + }, + }, + + 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(); + if (field === null) { + field = view.down('pbsNamespaceSelector'); + field.setValue(value); + return; + } + 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}/snapshots?trashed=1`; + if (view.namespace && view.namespace !== '') { + url += `&ns=${encodeURIComponent(view.namespace)}`; + } + this.store.setProxy({ + type: 'proxmox', + timeout: 300*1000, // 5 minutes, we should make that api call faster + url: url, + }); + + this.store.load(); + }, + + getRecordGroups: function(records) { + let groups = {}; + + for (const item of records) { + var btype = item.data["backup-type"]; + let group = btype + "/" + item.data["backup-id"]; + + if (groups[group] !== undefined) { + continue; + } + + var cls = PBS.Utils.get_type_icon_cls(btype); + if (cls === "") { + console.warn(`got unknown backup-type '${btype}'`); + continue; // FIXME: auto render? what do? + } + + groups[group] = { + text: group, + leaf: false, + iconCls: "fa " + cls, + expanded: false, + backup_type: item.data["backup-type"], + backup_id: item.data["backup-id"], + children: [], + }; + } + + return groups; + }, + + loadNamespaceFromSameLevel: async function() { + let view = this.getView(); + try { + let url = `/api2/extjs/admin/datastore/${view.datastore}/namespace?max-depth=1`; + if (view.namespace && view.namespace !== '') { + url += `&parent=${encodeURIComponent(view.namespace)}`; + } + url += '&include-trashed=1'; + let { result: { data: ns } } = await Proxmox.Async.api2({ url }); + return ns; + } catch (err) { + console.debug(err); + } + return []; + }, + + onLoad: async function(store, records, success, operation) { + let me = this; + let view = this.getView(); + + let namespaces = await me.loadNamespaceFromSameLevel(); + + if (!success) { + // TODO also check error code for != 403 ? + if (namespaces.length === 0) { + let error = Proxmox.Utils.getResponseErrorMessage(operation.getError()); + Proxmox.Utils.setErrorMask(view.down('treeview'), error); + return; + } else { + records = []; + } + } else { + Proxmox.Utils.setErrorMask(view.down('treeview')); + } + + let groups = this.getRecordGroups(records); + + let selected; + let expanded = {}; + + view.getSelection().some(function(item) { + let id = item.data.text; + if (item.data.leaf) { + id = item.parentNode.data.text + id; + } + selected = id; + return true; + }); + + 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, + }); + + for (const item of records) { + let group = item.data["backup-type"] + "/" + item.data["backup-id"]; + let children = groups[group].children; + + let data = item.data; + + data.text = group + '/' + PBS.Utils.render_datetime_utc(data["backup-time"]); + data.leaf = false; + data.cls = 'no-leaf-icons'; + data.matchesFilter = true; + data.ty = 'dir'; + + data.expanded = !!expanded[data.text]; + + data.children = []; + for (const file of data.files) { + file.text = file.filename; + file['crypt-mode'] = PBS.Utils.cryptmap.indexOf(file['crypt-mode']); + file.fingerprint = data.fingerprint; + file.leaf = true; + file.matchesFilter = true; + file.ty = 'file'; + + data.children.push(file); + } + + children.push(data); + } + + let children = []; + for (const [name, group] of Object.entries(groups)) { + let last_backup = 0; + let crypt = { + none: 0, + mixed: 0, + 'sign-only': 0, + encrypt: 0, + }; + for (let item of group.children) { + crypt[PBS.Utils.cryptmap[item['crypt-mode']]]++; + if (item["backup-time"] > last_backup && item.size !== null) { + last_backup = item["backup-time"]; + group["backup-time"] = last_backup; + group["last-comment"] = item.comment; + group.files = item.files; + group.size = item.size; + group.owner = item.owner; + } + } + group.count = group.children.length; + group.matchesFilter = true; + crypt.count = group.count; + group['crypt-mode'] = PBS.Utils.calculateCryptMode(crypt); + group.expanded = !!expanded[name]; + group.sortWeight = 0; + group.ty = 'group'; + children.push(group); + } + + for (const item of namespaces) { + if (item.ns === view.namespace || (!view.namespace && item.ns === '')) { + continue; + } + children.push({ + text: item.ns, + iconCls: 'fa fa-object-group', + expanded: true, + expandable: false, + ns: (view.namespaces ?? '') !== '' ? `/${item.ns}` : item.ns, + ty: 'ns', + sortWeight: 10, + leaf: true, + }); + } + + 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 (!children.length) { + view.setEmptyText(Ext.String.format( + gettext('No accessible snapshots found in namespace {0}'), + view.namespace && view.namespace !== '' ? `'${view.namespace}'`: gettext('Root'), + )); + } + + if (selected !== undefined) { + let selection = view.getRootNode().findChildBy(function(item) { + let id = item.data.text; + if (item.data.leaf) { + id = item.parentNode.data.text + id; + } + return selected === id; + }, undefined, true); + if (selection) { + view.setSelection(selection); + view.getView().focusRow(selection); + } + } + + Proxmox.Utils.setErrorMask(view, false); + if (view.getStore().getFilters().length > 0) { + let searchBox = me.lookup("searchbox"); + let searchvalue = searchBox.getValue(); + me.search(searchBox, searchvalue); + } + }, + + recoverNamespace: function(data) { + let me = this; + let view = me.getView(); + if (!view.namespace || view.namespace === '') { + console.warn('recoverNamespace called with root NS!'); + return; + } + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: gettext('Are you sure you want to recover all namespace contents?'), + buttons: Ext.Msg.YESNO, + defaultFocus: 'yes', + callback: function(btn) { + if (btn !== 'yes') { + return; + } + let params = { "ns": view.namespace }; + + Proxmox.Utils.API2Request({ + url: `/admin/datastore/${view.datastore}/recover-namespace`, + params, + method: 'POST', + waitMsgTarget: view, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + forgetNamespace: function(data) { + let me = this; + let view = me.getView(); + if (!view.namespace || view.namespace === '') { + console.warn('forgetNamespace called with root NS!'); + return; + } + let nsParts = view.namespace.split('/'); + let nsName = nsParts.pop(); + let parentNS = nsParts.join('/'); + + Ext.create('PBS.window.NamespaceDelete', { + datastore: view.datastore, + namespace: view.namespace, + item: { id: nsName }, + apiCallDone: success => { + if (success) { + view.namespace = parentNS; // move up before reload to avoid "ENOENT" error + me.reload(); + } + }, + }); + }, + + recoverGroup: function(data) { + let me = this; + let view = me.getView(); + + let params = { + "backup-type": data.backup_type, + "backup-id": data.backup_id, + }; + if (view.namespace && view.namespace !== '') { + params.ns = view.namespace; + } + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.String.format(gettext('Are you sure you want to recover group {0}'), `'${data.text}'`), + buttons: Ext.Msg.YESNO, + defaultFocus: 'yes', + callback: function(btn) { + if (btn !== 'yes') { + return; + } + + Proxmox.Utils.API2Request({ + url: `/admin/datastore/${view.datastore}/recover-group`, + params, + method: 'POST', + waitMsgTarget: view, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + forgetGroup: function(data) { + let me = this; + let view = me.getView(); + + let params = { + "backup-type": data.backup_type, + "backup-id": data.backup_id, + "skip-trash": true, + }; + if (view.namespace && view.namespace !== '') { + params.ns = view.namespace; + } + + Ext.create('Proxmox.window.SafeDestroy', { + url: `/admin/datastore/${view.datastore}/groups`, + params, + item: { + id: data.text, + }, + autoShow: true, + taskName: 'forget-group', + listeners: { + destroy: () => me.reload(), + }, + }); + }, + + recoverSnapshot: function(data) { + let me = this; + let view = me.getView(); + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.String.format(gettext('Are you sure you want to recover snapshot {0}'), `'${data.text}'`), + buttons: Ext.Msg.YESNO, + defaultFocus: 'yes', + callback: function(btn) { + if (btn !== 'yes') { + return; + } + let params = { + "backup-type": data["backup-type"], + "backup-id": data["backup-id"], + "backup-time": (data['backup-time'].getTime()/1000).toFixed(0), + }; + if (view.namespace && view.namespace !== '') { + params.ns = view.namespace; + } + + //TODO adapt to recover api endpoint + Proxmox.Utils.API2Request({ + url: `/admin/datastore/${view.datastore}/recover-snapshot`, + params, + method: 'POST', + waitMsgTarget: view, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + forgetSnapshot: function(data) { + let me = this; + let view = me.getView(); + + Ext.Msg.show({ + title: gettext('Confirm'), + icon: Ext.Msg.WARNING, + message: Ext.String.format(gettext('Are you sure you want to remove snapshot {0}'), `'${data.text}'`), + buttons: Ext.Msg.YESNO, + defaultFocus: 'no', + callback: function(btn) { + if (btn !== 'yes') { + return; + } + let params = { + "backup-type": data["backup-type"], + "backup-id": data["backup-id"], + "backup-time": (data['backup-time'].getTime()/1000).toFixed(0), + "skip-trash": true, + }; + if (view.namespace && view.namespace !== '') { + params.ns = view.namespace; + } + + Proxmox.Utils.API2Request({ + url: `/admin/datastore/${view.datastore}/snapshots`, + params, + method: 'DELETE', + waitMsgTarget: view, + failure: function(response, opts) { + Ext.Msg.alert(gettext('Error'), response.htmlStatus); + }, + callback: me.reload.bind(me), + }); + }, + }); + }, + + onRecover: function(table, rI, cI, item, e, { data }) { + let me = this; + let view = this.getView(); + if ((data.ty !== 'group' && data.ty !== 'dir' && data.ty !== 'ns') || !view.datastore) { + return; + } + + if (data.ty === 'ns') { + me.recoverNamespace(data); + } else if (data.ty === 'dir') { + me.recoverSnapshot(data); + } else { + me.recoverGroup(data); + } + }, + + onForget: function(table, rI, cI, item, e, { data }) { + let me = this; + let view = this.getView(); + if ((data.ty !== 'group' && data.ty !== 'dir' && data.ty !== 'ns') || !view.datastore) { + return; + } + + if (data.ty === 'ns') { + me.forgetNamespace(data); + } else if (data.ty === 'dir') { + me.forgetSnapshot(data); + } else { + me.forgetGroup(data); + } + }, + + // opens a namespace browser + openBrowser: function(tv, rI, Ci, item, e, rec) { + let me = this; + if (rec.data.ty === 'ns') { + me.nsChange(null, rec.data.ns); + } + }, + + filter: function(item, value) { + if (item.data.text.indexOf(value) !== -1) { + return true; + } + + if (item.data.owner && item.data.owner.indexOf(value) !== -1) { + return true; + } + + return false; + }, + + 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); + 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() { + let me = this; + me.getController().reload(); + }, + itemcontextmenu: function(panel, record, item, index, event) { + event.stopEvent(); + let title; + let view = panel.up('pbsDataStoreRecoverTrashed'); + let controller = view.getController(); + let createControllerCallback = function(name) { + return function() { + controller[name](view, undefined, undefined, undefined, undefined, record); + }; + }; + if (record.data.ty === 'group') { + title = gettext('Group'); + } else if (record.data.ty === 'dir') { + title = gettext('Snapshot'); + } else if (record.data.ty === 'ns') { + title = gettext('Namespace'); + } + if (title) { + let menu = Ext.create('PBS.datastore.RecoverTrashedContextMenu', { + title: title, + onRecover: createControllerCallback('onRecover'), + onForget: createControllerCallback('onForget'), + }); + menu.showAt(event.getXY()); + } + }, + }, + + columns: [ + { + xtype: 'treecolumn', + header: gettext("Backup Group"), + dataIndex: 'text', + renderer: (value, meta, record) => { + if (record.data.protected) { + return `${value} (${gettext('protected')})`; + } + return value; + }, + flex: 1, + }, + { + text: gettext('Comment'), + dataIndex: 'comment', + flex: 1, + renderer: (v, meta, record) => { + let data = record.data; + if (!data || data.leaf || data.root) { + return ''; + } + + let additionalClasses = ""; + if (!v) { + if (!data.expanded) { + v = data['last-comment'] ?? ''; + additionalClasses = 'pmx-opacity-75'; + } else { + v = ''; + } + } + v = Ext.String.htmlEncode(v); + return `<span class="snapshot-comment-column ${additionalClasses}">${v}</span>`; + }, + }, + { + header: gettext('Actions'), + xtype: 'actioncolumn', + dataIndex: 'text', + width: 80, + items: [ + { + handler: 'onRecover', + getTip: (v, m, { data }) => { + let tip = '{0}'; + if (data.ty === 'ns') { + tip = gettext("Recover all namespace contents"); + } else if (data.ty === 'dir') { + tip = gettext("Recover snapshot '{0}'"); + } else if (data.ty === 'group') { + tip = gettext("Recover group '{0}'"); + } + return Ext.String.format(tip, v); + }, + getClass: (v, m, { data }) => + (data.ty === 'ns' && !data.isRootNS && data.ns === undefined) || + data.ty === 'group' || data.ty === 'dir' + ? 'fa fa-rotate-left' + : 'pmx-hidden', + isActionDisabled: (v, r, c, i, { data }) => false, + }, + '->', + { + handler: 'onForget', + getTip: (v, m, { data }) => { + let tip = '{0}'; + if (data.ty === 'ns') { + tip = gettext("Permanently forget namespace contents '{0}'"); + } else if (data.ty === 'dir') { + tip = gettext("Permanently forget snapshot '{0}'"); + } else if (data.ty === 'group') { + tip = gettext("Permanently forget group '{0}'"); + } + return Ext.String.format(tip, v); + }, + getClass: (v, m, { data }) => + (data.ty === 'ns' && !data.isRootNS && data.ns === undefined) || + data.ty === 'group' || data.ty === 'dir' + ? 'fa critical fa-trash-o' + : 'pmx-hidden', + isActionDisabled: (v, r, c, i, { data }) => false, + }, + { + handler: 'openBrowser', + tooltip: gettext('Browse'), + getClass: (v, m, { data }) => data.ty === 'ns' && !data.root + ? 'fa fa-folder-open-o' + : 'pmx-hidden', + isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'ns', + }, + ], + }, + { + xtype: 'datecolumn', + header: gettext('Backup Time'), + sortable: true, + dataIndex: 'backup-time', + format: 'Y-m-d H:i:s', + width: 150, + }, + { + header: gettext("Size"), + sortable: true, + dataIndex: 'size', + renderer: (v, meta, { data }) => { + if ((data.text === 'client.log.blob' && v === undefined) || (data.ty !== 'dir' && data.ty !== 'file')) { + return ''; + } + if (v === undefined || v === null) { + meta.tdCls = "x-grid-row-loading"; + return ''; + } + return Proxmox.Utils.format_size(v); + }, + }, + { + xtype: 'numbercolumn', + format: '0', + header: gettext("Count"), + sortable: true, + width: 75, + align: 'right', + dataIndex: 'count', + }, + { + header: gettext("Owner"), + sortable: true, + dataIndex: 'owner', + }, + { + header: gettext('Encrypted'), + dataIndex: 'crypt-mode', + renderer: (v, meta, record) => { + if (record.data.size === undefined || record.data.size === null) { + return ''; + } + if (v === -1) { + return ''; + } + let iconCls = PBS.Utils.cryptIconCls[v] || ''; + let iconTxt = ""; + if (iconCls) { + iconTxt = `<i class="fa fa-fw fa-${iconCls}"></i> `; + } + let tip; + if (v !== PBS.Utils.cryptmap.indexOf('none') && record.data.fingerprint !== undefined) { + tip = "Key: " + PBS.Utils.renderKeyID(record.data.fingerprint); + } + let txt = (iconTxt + PBS.Utils.cryptText[v]) || Proxmox.Utils.unknownText; + if (record.data.ty === 'group' || tip === undefined) { + return txt; + } else { + return `<span data-qtip="${tip}">${txt}</span>`; + } + }, + }, + ], + + tbar: [ + { + text: gettext('Reload'), + iconCls: 'fa fa-refresh', + handler: 'reload', + }, + '->', + { + xtype: 'tbtext', + html: gettext('Namespace') + ':', + }, + { + xtype: 'pbsNamespaceSelector', + width: 200, + cbind: { + datastore: '{datastore}', + }, + }, + '-', + { + xtype: 'tbtext', + html: gettext('Search'), + }, + { + xtype: 'textfield', + reference: 'searchbox', + emptyText: gettext('group, date or owner'), + 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, + }, + }, + }, + ], +}); + +Ext.define('PBS.datastore.RecoverTrashedContextMenu', { + extend: 'Ext.menu.Menu', + mixins: ['Proxmox.Mixin.CBind'], + + onRecover: undefined, + onForget: undefined, + + items: [ + { + text: gettext('Recover'), + iconCls: 'fa critical fa-rotate-left', + handler: function() { this.up('menu').onRecover(); }, + cbind: { + hidden: '{!onRecover}', + }, + }, + { + text: gettext('Remove'), + iconCls: 'fa critical fa-trash-o', + handler: function() { this.up('menu').onForget(); }, + cbind: { + hidden: '{!onForget}', + }, + }, + ], +}); -- 2.39.5 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel