proxmox-backup/www/datastore/Content.js
Thomas Lamprecht 70493f1823 ui: datastore content: better cope with restricted privs on parent namespaces
Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
2022-05-27 16:09:48 +02:00

1243 lines
31 KiB
JavaScript

Ext.define('pbs-data-store-snapshots', {
extend: 'Ext.data.Model',
fields: [
'backup-type',
'backup-id',
{
name: 'backup-time',
type: 'date',
dateFormat: 'timestamp',
},
'comment',
'files',
'owner',
'verification',
'fingerprint',
{ name: 'size', type: 'int', allowNull: true },
{ name: 'sortWeight', type: 'int', allowNull: true },
{ name: 'ty', type: 'string', allowNull: true },
{
name: 'crypt-mode',
type: 'boolean',
calculate: function(data) {
let crypt = {
none: 0,
mixed: 0,
'sign-only': 0,
encrypt: 0,
count: 0,
};
data.files.forEach(file => {
if (file.filename === 'index.json.blob') return; // is never encrypted
let mode = PBS.Utils.cryptmap.indexOf(file['crypt-mode']);
if (mode !== -1) {
crypt[file['crypt-mode']]++;
crypt.count++;
}
});
return PBS.Utils.calculateCryptMode(crypt);
},
},
{
name: 'matchesFilter',
type: 'boolean',
defaultValue: true,
},
],
});
Ext.define('PBS.DataStoreContent', {
extend: 'Ext.tree.Panel',
alias: 'widget.pbsDataStoreContent',
mixins: ['Proxmox.Mixin.CBind'],
rootVisible: false,
title: gettext('Content'),
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`;
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;
},
updateGroupNotes: async function(view) {
try {
let url = `/api2/extjs/admin/datastore/${view.datastore}/groups`;
if (view.namespace && view.namespace !== '') {
url += `?ns=${encodeURIComponent(view.namespace)}`;
}
let { result: { data: groups } } = await Proxmox.Async.api2({ url });
let map = {};
for (const group of groups) {
map[`${group["backup-type"]}/${group["backup-id"]}`] = group.comment;
}
view.getRootNode().cascade(node => {
if (node.data.ty === 'group') {
let group = `${node.data.backup_type}/${node.data.backup_id}`;
node.set('comment', map[group], { dirty: false });
}
});
} catch (err) {
console.debug(err);
}
},
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)}`;
}
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 nowSeconds = Date.now() / 1000;
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,
};
let verify = {
outdated: 0,
none: 0,
failed: 0,
ok: 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.files = item.files;
group.size = item.size;
group.owner = item.owner;
verify.lastFailed = item.verification && item.verification.state !== 'ok';
}
if (!item.verification) {
verify.none++;
} else {
if (item.verification.state === 'ok') {
verify.ok++;
} else {
verify.failed++;
}
let task = Proxmox.Utils.parse_task_upid(item.verification.upid);
item.verification.lastTime = task.starttime;
if (nowSeconds - task.starttime > 30 * 24 * 60 * 60) {
verify.outdated++;
}
}
}
group.verification = verify;
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'),
));
}
this.updateGroupNotes(view);
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);
}
},
onChangeOwner: function(table, rI, cI, item, e, { data }) {
let view = this.getView();
if (data.ty !== 'group' || !view.datastore) {
return;
}
let win = Ext.create('PBS.BackupGroupChangeOwner', {
datastore: view.datastore,
ns: view.namespace,
backup_type: data.backup_type,
backup_id: data.backup_id,
owner: data.owner,
autoShow: true,
});
// FIXME: don't reload all, use the record and query only its info, then update it
// directly in the tree
win.on('destroy', this.reload, this);
},
onPrune: function(table, rI, cI, item, e, rec) {
let me = this;
let view = me.getView();
if (rec.data.ty !== 'group' || !view.datastore) {
return;
}
let data = rec.data;
Ext.create('PBS.DataStorePrune', {
autoShow: true,
datastore: view.datastore,
ns: view.namespace,
backup_type: data.backup_type,
backup_id: data.backup_id,
listeners: {
// FIXME: don't reload all, use the record and query only its info, then update
// it directly in the tree
destroy: () => me.reload(),
},
});
},
verifyAll: function() {
let me = this;
let view = me.getView();
Ext.create('PBS.window.VerifyAll', {
taskDone: () => me.reload(),
autoShow: true,
datastore: view.datastore,
namespace: view.namespace,
});
},
pruneAll: function() {
let me = this;
let view = me.getView();
if (!view.datastore) return;
let ns = view.namespace;
let titleNS = ns && ns !== '' ? `Namespace '${ns}' on ` : '';
Ext.create('Proxmox.window.Edit', {
title: `Prune ${titleNS}Datastore '${view.datastore}'`,
onlineHelp: 'maintenance_pruning',
method: 'POST',
submitText: "Prune",
autoShow: true,
isCreate: true,
showTaskViewer: true,
taskDone: () => me.reload(),
url: `/api2/extjs/admin/datastore/${view.datastore}/prune-datastore`,
items: [
{
xtype: 'pbsPruneInputPanel',
ns,
dryrun: true,
canRecurse: true,
},
],
});
},
addNS: function() {
let me = this;
let view = me.getView();
if (!view.datastore) return;
Ext.create('PBS.window.NamespaceEdit', {
autoShow: true,
datastore: view.datastore,
namespace: view.namespace ?? '',
apiCallDone: success => {
if (success) {
view.down('pbsNamespaceSelector').store?.load();
me.reload();
}
},
});
},
onVerify: function(view, rI, cI, item, e, { data }) {
let me = this;
view = me.getView();
if ((data.ty !== 'group' && data.ty !== 'dir') || !view.datastore) {
return;
}
let params;
if (data.ty === 'dir') {
params = {
"backup-type": data["backup-type"],
"backup-id": data["backup-id"],
"backup-time": (data['backup-time'].getTime()/1000).toFixed(0),
"ignore-verified": false, // always reverify single snapshots
};
} else {
params = {
"backup-type": data.backup_type,
"backup-id": data.backup_id,
"outdated-after": 29, // reverify after 29 days so match with the "old" display
};
}
if (view.namespace && view.namespace !== '') {
params.ns = view.namespace;
}
Proxmox.Utils.API2Request({
params: params,
url: `/admin/datastore/${view.datastore}/verify`,
method: 'POST',
failure: function(response) {
Ext.Msg.alert(gettext('Error'), response.htmlStatus);
},
success: function(response, options) {
Ext.create('Proxmox.window.TaskViewer', {
upid: response.result.data,
taskDone: () => me.reload(),
}).show();
},
});
},
onNotesEdit: function(view, data) {
let me = this;
let isGroup = data.ty === 'group';
let params;
if (isGroup) {
params = {
"backup-type": data.backup_type,
"backup-id": data.backup_id,
};
} else {
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;
}
Ext.create('PBS.window.NotesEdit', {
url: `/admin/datastore/${view.datastore}/${isGroup ? 'group-notes' : 'notes'}`,
autoShow: true,
apiCallDone: () => me.reload(), // FIXME: do something more efficient?
extraRequestParams: params,
});
},
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();
}
},
});
},
forgetGroup: 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.create('Proxmox.window.SafeDestroy', {
url: `/admin/datastore/${view.datastore}/groups`,
params,
item: {
id: data.text,
},
autoShow: true,
taskName: 'forget-group',
listeners: {
destroy: () => me.reload(),
},
});
},
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),
};
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),
});
},
});
},
onProtectionChange: function(view, rI, cI, item, e, rec) {
let me = this;
view = this.getView();
if (!(rec && rec.data)) return;
let data = rec.data;
if (!view.datastore) return;
let type = data["backup-type"];
let id = data["backup-id"];
let time = (data["backup-time"].getTime()/1000).toFixed(0);
let params = {
'backup-type': type,
'backup-id': id,
'backup-time': time,
};
if (view.namespace && view.namespace !== '') {
params.ns = view.namespace;
}
let url = `/api2/extjs/admin/datastore/${view.datastore}/protected`;
Ext.create('Proxmox.window.Edit', {
subject: gettext('Protection') + ` - ${data.text}`,
width: 400,
method: 'PUT',
autoShow: true,
isCreate: false,
autoLoad: true,
url,
extraRequestParams: params,
items: [
{
xtype: 'proxmoxcheckbox',
fieldLabel: gettext('Protected'),
uncheckedValue: 0,
name: 'protected',
value: data.protected,
},
],
listeners: {
destroy: () => me.reload(),
},
});
},
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);
}
},
downloadFile: function(tV, rI, cI, item, e, rec) {
let me = this;
let view = me.getView();
if (rec.data.ty !== 'file') return;
let snapshot = rec.parentNode.data;
let file = rec.data.filename;
let params = {
'backup-id': snapshot['backup-id'],
'backup-type': snapshot['backup-type'],
'backup-time': (snapshot['backup-time'].getTime()/1000).toFixed(0),
'file-name': file,
};
if (view.namespace && view.namespace !== '') {
params.ns = view.namespace;
}
let idx = file.lastIndexOf('.');
let filename = file.slice(0, idx);
let atag = document.createElement('a');
atag.download = filename;
let url = new URL(
`/api2/json/admin/datastore/${view.datastore}/download-decoded`,
window.location.origin,
);
for (const [key, value] of Object.entries(params)) {
url.searchParams.append(key, value);
}
atag.href = url.href;
atag.click();
},
// opens either a namespace or a pxar file-browser
openBrowser: function(tv, rI, Ci, item, e, rec) {
let me = this;
let view = me.getView();
if (rec.data.ty === 'ns') {
me.nsChange(null, rec.data.ns);
return;
}
if (rec?.data?.ty !== 'file') return;
let snapshot = rec.parentNode.data;
let id = snapshot['backup-id'];
let time = snapshot['backup-time'];
let type = snapshot['backup-type'];
let timetext = PBS.Utils.render_datetime_utc(snapshot["backup-time"]);
let extraParams = {
'backup-id': id,
'backup-time': (time.getTime()/1000).toFixed(0),
'backup-type': type,
};
if (view.namespace && view.namespace !== '') {
extraParams.ns = view.namespace;
}
Ext.create('Proxmox.window.FileBrowser', {
title: `${type}/${id}/${timetext}`,
listURL: `/api2/json/admin/datastore/${view.datastore}/catalog`,
downloadURL: `/api2/json/admin/datastore/${view.datastore}/pxar-file-download`,
extraParams,
enableTar: true,
downloadPrefix: `${type}-${id}-`,
archive: rec.data.filename,
}).show();
},
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;
// only load on first activate to not load every tab switch
if (!me.firstLoad) {
me.getController().reload();
me.firstLoad = true;
}
},
},
viewConfig: {
getRowClass: function(record, index) {
let verify = record.get('verification');
if (verify && verify.lastFailed) {
return 'proxmox-invalid-row';
}
return null;
},
},
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 '';
}
if (v === undefined || v === null) {
v = '';
}
v = Ext.String.htmlEncode(v);
let icon = 'x-action-col-icon fa fa-fw fa-pencil pointer';
return `<span class="snapshot-comment-column">${v}</span>
<i data-qtip="${gettext('Edit')}" style="float: right; margin: 0px;" class="${icon}"></i>`;
},
listeners: {
afterrender: function(component) {
// a bit of a hack, but relatively easy, cheap and works out well.
// more efficient to use one handler for the whole column than for each icon
component.on('click', function(tree, cell, rowI, colI, e, rec) {
let el = e.target;
if (el.tagName !== "I" || !el.classList.contains("fa-pencil")) {
return;
}
let view = tree.up();
let controller = view.controller;
controller.onNotesEdit(view, rec.data);
});
},
dblclick: function(tree, el, row, col, ev, rec) {
let data = rec.data || {};
if (data.leaf || data.root) {
return;
}
let view = tree.up();
let controller = view.controller;
controller.onNotesEdit(view, rec.data);
},
},
},
{
header: gettext('Actions'),
xtype: 'actioncolumn',
dataIndex: 'text',
width: 150,
items: [
{
handler: 'onVerify',
getTip: (v, m, rec) => Ext.String.format(gettext("Verify '{0}'"), v),
getClass: (v, m, { data }) => data.ty === 'group' || data.ty === 'dir'
? 'pve-icon-verify-lettering' : 'pmx-hidden',
isActionDisabled: (v, r, c, i, rec) => !!rec.data.leaf,
},
{
handler: 'onChangeOwner',
getClass: (v, m, { data }) => data.ty === 'group' ? 'fa fa-user' : 'pmx-hidden',
getTip: (v, m, rec) => Ext.String.format(gettext("Change owner of '{0}'"), v),
isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group',
},
{
handler: 'onPrune',
getTip: (v, m, rec) => Ext.String.format(gettext("Prune '{0}'"), v),
getClass: (v, m, { data }) => data.ty === 'group' ? 'fa fa-scissors' : 'pmx-hidden',
isActionDisabled: (v, r, c, i, { data }) => data.ty !== 'group',
},
{
handler: 'onProtectionChange',
getTip: (v, m, rec) => Ext.String.format(gettext("Change protection of '{0}'"), v),
getClass: (v, m, rec) => {
if (rec.data.ty === 'dir') {
let extraCls = rec.data.protected ? 'good' : 'faded';
return `fa fa-shield ${extraCls}`;
}
return 'pmx-hidden';
},
isActionDisabled: (v, r, c, i, rec) => rec.data.ty !== 'dir',
},
{
handler: 'onForget',
getTip: (v, m, { data }) => {
let tip = '{0}';
if (data.ty === 'ns') {
tip = gettext("Remove namespace '{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: 'downloadFile',
getTip: (v, m, rec) => Ext.String.format(gettext("Download '{0}'"), v),
getClass: (v, m, { data }) => data.ty === 'file' ? 'fa fa-download' : 'pmx-hidden',
isActionDisabled: (v, r, c, i, rec) => rec.data.ty !== 'file' || rec.data['crypt-mode'] > 2,
},
{
handler: 'openBrowser',
tooltip: gettext('Browse'),
getClass: (v, m, { data }) => {
if (
(data.ty === 'file' && data.filename.endsWith('pxar.didx')) ||
(data.ty === 'ns' && !data.root)
) {
return 'fa fa-folder-open-o';
}
return 'pmx-hidden';
},
isActionDisabled: (v, r, c, i, { data }) =>
!(data.ty === 'file' && data.filename.endsWith('pxar.didx') && data['crypt-mode'] < 3) && 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>`;
}
},
},
{
header: gettext('Verify State'),
sortable: true,
dataIndex: 'verification',
width: 120,
sorter: (arec, brec) => {
let a = arec.data.verification || { ok: 0, outdated: 0, failed: 0 };
let b = brec.data.verification || { ok: 0, outdated: 0, failed: 0 };
if (a.failed === b.failed) {
if (a.none === b.none) {
if (a.outdated === b.outdated) {
return b.ok - a.ok;
} else {
return b.outdated - a.outdated;
}
} else {
return b.none - a.none;
}
} else {
return b.failed - a.failed;
}
},
renderer: (v, meta, record) => {
if (record.data.ty === 'ns') {
return ''; // TODO: accumulate verify of all groups into root NS node?
}
let i = (cls, txt) => `<i class="fa fa-fw fa-${cls}"></i> ${txt}`;
if (v === undefined || v === null) {
return record.data.leaf ? '' : i('question-circle-o warning', gettext('None'));
}
let tip, iconCls, txt;
if (record.data.ty === 'group') {
if (v.failed === 0) {
if (v.none === 0) {
if (v.outdated > 0) {
tip = 'All OK, but some snapshots were not verified in last 30 days';
iconCls = 'check warning';
txt = gettext('All OK (old)');
} else {
tip = 'All snapshots verified at least once in last 30 days';
iconCls = 'check good';
txt = gettext('All OK');
}
} else if (v.ok === 0) {
tip = `${v.none} not verified yet`;
iconCls = 'question-circle-o warning';
txt = gettext('None');
} else {
tip = `${v.ok} OK, ${v.none} not verified yet`;
iconCls = 'check faded';
txt = `${v.ok} OK`;
}
} else {
tip = `${v.ok} OK, ${v.failed} failed, ${v.none} not verified yet`;
iconCls = 'times critical';
txt = v.ok === 0 && v.none === 0
? gettext('All failed')
: `${v.failed} failed`;
}
} else if (!v.state) {
return record.data.leaf ? '' : gettext('None');
} else {
let verify_time = Proxmox.Utils.render_timestamp(v.lastTime);
tip = `Last verify task started on ${verify_time}`;
txt = v.state;
iconCls = 'times critical';
if (v.state === 'ok') {
iconCls = 'check good';
let now = Date.now() / 1000;
if (now - v.lastTime > 30 * 24 * 60 * 60) {
tip = `Last verify task over 30 days ago: ${verify_time}`;
iconCls = 'check warning';
}
}
}
return `<span data-qtip="${tip}">
<i class="fa fa-fw fa-${iconCls}"></i> ${txt}
</span>`;
},
listeners: {
dblclick: function(view, el, row, col, ev, rec) {
let verify = rec?.data?.verification;
if (verify?.upid && rec.parentNode?.id !== 'root') {
Ext.create('Proxmox.window.TaskViewer', {
autoShow: true,
upid: verify.upid,
});
}
},
},
},
],
tbar: [
{
text: gettext('Reload'),
iconCls: 'fa fa-refresh',
handler: 'reload',
},
'-',
{
xtype: 'proxmoxButton',
text: gettext('Verify All'),
iconCls: 'pve-icon-verify-lettering',
handler: 'verifyAll',
},
{
xtype: 'proxmoxButton',
text: gettext('Prune All'),
iconCls: 'fa fa-scissors',
handler: 'pruneAll',
},
'->',
{
xtype: 'tbtext',
html: gettext('Namespace') + ':',
},
{
xtype: 'pbsNamespaceSelector',
width: 200,
cbind: {
datastore: '{datastore}',
},
},
{
xtype: 'proxmoxButton',
text: gettext('Add NS'),
iconCls: 'fa fa-plus-square',
handler: 'addNS',
},
'-',
{
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,
},
},
},
],
});