[pbs-devel] [RFC v2 proxmox-backup 19/21] ui: add recover for trashed items tab to datastore panel

Christian Ebner c.ebner at proxmox.com
Thu May 8 15:05:53 CEST 2025


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 at 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





More information about the pbs-devel mailing list